dirigent-core 0.9.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.
- dirigent_core/__init__.py +26 -0
- dirigent_core/alembic/env.py +71 -0
- dirigent_core/alembic/script.py.mako +26 -0
- dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
- dirigent_core/alerting.py +805 -0
- dirigent_core/artifacts.py +73 -0
- dirigent_core/auth.py +529 -0
- dirigent_core/blockdocs.py +223 -0
- dirigent_core/config.py +421 -0
- dirigent_core/configdocs.py +134 -0
- dirigent_core/database.py +177 -0
- dirigent_core/directory.py +339 -0
- dirigent_core/documents.py +878 -0
- dirigent_core/documentschema.py +92 -0
- dirigent_core/engine/__init__.py +120 -0
- dirigent_core/engine/claim.py +156 -0
- dirigent_core/engine/context.py +420 -0
- dirigent_core/engine/definition.py +543 -0
- dirigent_core/engine/executor.py +1029 -0
- dirigent_core/engine/failure.py +71 -0
- dirigent_core/engine/recovery.py +137 -0
- dirigent_core/engine/references.py +239 -0
- dirigent_core/engine/runs.py +865 -0
- dirigent_core/engine/services.py +74 -0
- dirigent_core/engine/state.py +434 -0
- dirigent_core/ids.py +39 -0
- dirigent_core/logging.py +310 -0
- dirigent_core/migrations.py +98 -0
- dirigent_core/models.py +626 -0
- dirigent_core/pipelines.py +494 -0
- dirigent_core/plugins.py +255 -0
- dirigent_core/protocol.py +276 -0
- dirigent_core/py.typed +0 -0
- dirigent_core/ratelimit.py +42 -0
- dirigent_core/registry.py +32 -0
- dirigent_core/retention.py +293 -0
- dirigent_core/scheduler.py +491 -0
- dirigent_core/schemas.py +147 -0
- dirigent_core/secrets.py +164 -0
- dirigent_core/storage.py +349 -0
- dirigent_core/telemetry.py +402 -0
- dirigent_core/trigger_documents.py +193 -0
- dirigent_core/triggers/__init__.py +117 -0
- dirigent_core/triggers/backfill.py +131 -0
- dirigent_core/triggers/materialize.py +218 -0
- dirigent_core/triggers/schedules.py +531 -0
- dirigent_core/triggers/webhooks.py +586 -0
- dirigent_core/types.py +59 -0
- dirigent_core/worker.py +350 -0
- dirigent_core-0.9.0.dist-info/METADATA +29 -0
- dirigent_core-0.9.0.dist-info/RECORD +53 -0
- dirigent_core-0.9.0.dist-info/WHEEL +4 -0
- dirigent_core-0.9.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The JSON Schema an editor checks a ``dirigent/v1`` document against.
|
|
2
|
+
|
|
3
|
+
The document format is a pydantic model and every block's config is another one, so a document
|
|
4
|
+
is only half-describable without the catalog: ``steps.push.config`` is whatever the block named
|
|
5
|
+
in ``steps.push.block`` accepts. This module composes the two into one Draft 2020-12 schema, so
|
|
6
|
+
an editor squiggles an unknown key in the place an apply would refuse it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Any, Final, cast
|
|
11
|
+
|
|
12
|
+
from dirigent_client.schemas import Catalog
|
|
13
|
+
from dirigent_common import HumaneJsonSchema, JsonMap
|
|
14
|
+
from dirigent_core.engine.definition import PipelineDefinition, TriggersDefinition
|
|
15
|
+
|
|
16
|
+
#: The dialect the composed schema declares, which is the one the engine validates parameters in.
|
|
17
|
+
DIALECT: Final = "https://json-schema.org/draft/2020-12/schema"
|
|
18
|
+
|
|
19
|
+
TITLE: Final = "dirigent/v1 document"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def document_schema(catalog: Catalog) -> JsonMap:
|
|
23
|
+
"""Compose both document kinds with this instance's own block config schemas.
|
|
24
|
+
|
|
25
|
+
The two branches share ``$defs``, and ``kind`` is what an editor picks between them by.
|
|
26
|
+
"""
|
|
27
|
+
pipeline = _pipeline_schema(catalog)
|
|
28
|
+
triggers = TriggersDefinition.model_json_schema(ref_template="#/$defs/{model}", schema_generator=HumaneJsonSchema)
|
|
29
|
+
definitions = cast("dict[str, Any]", pipeline.pop("$defs", {}))
|
|
30
|
+
definitions.update(cast("dict[str, Any]", triggers.pop("$defs", {})))
|
|
31
|
+
return {
|
|
32
|
+
"$schema": DIALECT,
|
|
33
|
+
"title": TITLE,
|
|
34
|
+
"$defs": definitions,
|
|
35
|
+
"oneOf": [pipeline, triggers],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pipeline_schema(catalog: Catalog) -> JsonMap:
|
|
40
|
+
"""The pipeline branch: the document format with every block's config grafted onto its step."""
|
|
41
|
+
schema = PipelineDefinition.model_json_schema(schema_generator=HumaneJsonSchema)
|
|
42
|
+
definitions = cast("dict[str, Any]", schema.setdefault("$defs", {}))
|
|
43
|
+
step = cast("dict[str, Any]", definitions["StepDefinition"])
|
|
44
|
+
blocks = sorted(catalog.blocks, key=lambda entry: entry.id)
|
|
45
|
+
|
|
46
|
+
if blocks:
|
|
47
|
+
cast("dict[str, Any]", step["properties"])["block"]["enum"] = [entry.id for entry in blocks]
|
|
48
|
+
|
|
49
|
+
cases: list[JsonMap] = []
|
|
50
|
+
for entry in blocks:
|
|
51
|
+
config, lifted = _lift(entry.config_schema, entry.id)
|
|
52
|
+
definitions.update(lifted)
|
|
53
|
+
cases.append(
|
|
54
|
+
{
|
|
55
|
+
"if": {"properties": {"block": {"const": entry.id}}, "required": ["block"]},
|
|
56
|
+
"then": {"properties": {"config": config}},
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
if cases:
|
|
60
|
+
step["allOf"] = cases
|
|
61
|
+
return schema
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _lift(config_schema: JsonMap, block_id: str) -> tuple[JsonMap, dict[str, Any]]:
|
|
65
|
+
"""Move one block's ``$defs`` into the document's, under names only that block uses.
|
|
66
|
+
|
|
67
|
+
Two blocks contribute a ``Size`` each and a shared ``$defs`` has room for one, so every
|
|
68
|
+
definition is renamed for the block it came from and every ``$ref`` to it follows.
|
|
69
|
+
"""
|
|
70
|
+
own = cast("dict[str, Any]", config_schema.get("$defs", {}))
|
|
71
|
+
body = {key: value for key, value in config_schema.items() if key != "$defs"}
|
|
72
|
+
lifted = {f"{block_id}.{name}": _repoint(value, block_id) for name, value in own.items()}
|
|
73
|
+
return cast("JsonMap", _repoint(body, block_id)), lifted
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _repoint(value: object, block_id: str) -> Any:
|
|
77
|
+
"""Point every local ``$ref`` at the name its definition was lifted under."""
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
mapping = cast("Mapping[str, object]", value)
|
|
80
|
+
return {key: _repoint_member(key, item, block_id) for key, item in mapping.items()}
|
|
81
|
+
if isinstance(value, list):
|
|
82
|
+
items = cast("Sequence[object]", value)
|
|
83
|
+
return [_repoint(item, block_id) for item in items]
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _repoint_member(key: str, value: object, block_id: str) -> Any:
|
|
88
|
+
"""Rewrite one member, which is a rename only when it is the ``$ref`` itself."""
|
|
89
|
+
prefix = "#/$defs/"
|
|
90
|
+
if key == "$ref" and isinstance(value, str) and value.startswith(prefix):
|
|
91
|
+
return f"{prefix}{block_id}.{value.removeprefix(prefix)}"
|
|
92
|
+
return _repoint(value, block_id)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The execution engine: definitions, references, claims, execution, and recovery."""
|
|
2
|
+
|
|
3
|
+
from dirigent_core.engine.claim import ClaimedUnit, due_attempt_statement, select_due
|
|
4
|
+
from dirigent_core.engine.context import (
|
|
5
|
+
BufferedLogger,
|
|
6
|
+
ConnectionRecord,
|
|
7
|
+
EngineStepContext,
|
|
8
|
+
load_connections,
|
|
9
|
+
load_schemas,
|
|
10
|
+
)
|
|
11
|
+
from dirigent_core.engine.definition import (
|
|
12
|
+
FORMAT_V1,
|
|
13
|
+
KIND_PIPELINE,
|
|
14
|
+
KIND_TRIGGERS,
|
|
15
|
+
ConcurrencyPolicy,
|
|
16
|
+
Document,
|
|
17
|
+
ItemPolicy,
|
|
18
|
+
ParameterError,
|
|
19
|
+
PipelineDefinition,
|
|
20
|
+
Requirements,
|
|
21
|
+
RetryPolicy,
|
|
22
|
+
ScheduleSpec,
|
|
23
|
+
StepDefinition,
|
|
24
|
+
TimeoutAction,
|
|
25
|
+
TriggerRule,
|
|
26
|
+
TriggersDefinition,
|
|
27
|
+
TriggerSpecs,
|
|
28
|
+
WebhookSpec,
|
|
29
|
+
canonical_document,
|
|
30
|
+
dump_definition,
|
|
31
|
+
load_definition,
|
|
32
|
+
)
|
|
33
|
+
from dirigent_core.engine.executor import Engine
|
|
34
|
+
from dirigent_core.engine.failure import Failure, backoff_delay, classify, should_retry
|
|
35
|
+
from dirigent_core.engine.recovery import detect_stuck_runs, sweep_leases
|
|
36
|
+
from dirigent_core.engine.references import ReferenceScope, UnknownReference, resolve, resolve_config
|
|
37
|
+
from dirigent_core.engine.runs import (
|
|
38
|
+
Attribution,
|
|
39
|
+
EngineRuns,
|
|
40
|
+
FanOutError,
|
|
41
|
+
Provenance,
|
|
42
|
+
RunCreationError,
|
|
43
|
+
cancel_run,
|
|
44
|
+
chain_depth,
|
|
45
|
+
create_run,
|
|
46
|
+
retry_step,
|
|
47
|
+
save_pipeline,
|
|
48
|
+
)
|
|
49
|
+
from dirigent_core.engine.services import EngineServices
|
|
50
|
+
from dirigent_core.engine.state import (
|
|
51
|
+
Readiness,
|
|
52
|
+
StepOutcome,
|
|
53
|
+
StepState,
|
|
54
|
+
advance,
|
|
55
|
+
aggregate_items,
|
|
56
|
+
build_step_states,
|
|
57
|
+
derive_run_status,
|
|
58
|
+
evaluate_rule,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"FORMAT_V1",
|
|
63
|
+
"KIND_PIPELINE",
|
|
64
|
+
"KIND_TRIGGERS",
|
|
65
|
+
"Attribution",
|
|
66
|
+
"BufferedLogger",
|
|
67
|
+
"ClaimedUnit",
|
|
68
|
+
"ConcurrencyPolicy",
|
|
69
|
+
"ConnectionRecord",
|
|
70
|
+
"Document",
|
|
71
|
+
"Engine",
|
|
72
|
+
"EngineRuns",
|
|
73
|
+
"EngineServices",
|
|
74
|
+
"EngineStepContext",
|
|
75
|
+
"Failure",
|
|
76
|
+
"FanOutError",
|
|
77
|
+
"ItemPolicy",
|
|
78
|
+
"ParameterError",
|
|
79
|
+
"PipelineDefinition",
|
|
80
|
+
"Provenance",
|
|
81
|
+
"Readiness",
|
|
82
|
+
"ReferenceScope",
|
|
83
|
+
"Requirements",
|
|
84
|
+
"RetryPolicy",
|
|
85
|
+
"RunCreationError",
|
|
86
|
+
"ScheduleSpec",
|
|
87
|
+
"StepDefinition",
|
|
88
|
+
"StepOutcome",
|
|
89
|
+
"StepState",
|
|
90
|
+
"TimeoutAction",
|
|
91
|
+
"TriggerRule",
|
|
92
|
+
"TriggerSpecs",
|
|
93
|
+
"TriggersDefinition",
|
|
94
|
+
"UnknownReference",
|
|
95
|
+
"WebhookSpec",
|
|
96
|
+
"advance",
|
|
97
|
+
"aggregate_items",
|
|
98
|
+
"backoff_delay",
|
|
99
|
+
"build_step_states",
|
|
100
|
+
"canonical_document",
|
|
101
|
+
"cancel_run",
|
|
102
|
+
"chain_depth",
|
|
103
|
+
"classify",
|
|
104
|
+
"create_run",
|
|
105
|
+
"derive_run_status",
|
|
106
|
+
"detect_stuck_runs",
|
|
107
|
+
"due_attempt_statement",
|
|
108
|
+
"dump_definition",
|
|
109
|
+
"evaluate_rule",
|
|
110
|
+
"load_connections",
|
|
111
|
+
"load_definition",
|
|
112
|
+
"load_schemas",
|
|
113
|
+
"resolve",
|
|
114
|
+
"resolve_config",
|
|
115
|
+
"retry_step",
|
|
116
|
+
"save_pipeline",
|
|
117
|
+
"select_due",
|
|
118
|
+
"should_retry",
|
|
119
|
+
"sweep_leases",
|
|
120
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The claim query: one statement that pulls the next due unit of work.
|
|
2
|
+
|
|
3
|
+
On PostgreSQL the claim takes ``FOR UPDATE SKIP LOCKED``, so concurrent workers step over
|
|
4
|
+
each other's rows. SQLite has no such clause, and the process-wide lock that stands in for it
|
|
5
|
+
is only correct when exactly one process exists -- the guarantee the standalone mode makes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import cast
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
16
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from dirigent_client.enums import PRIORITY_RANK, AttemptStatus, RunPriority, RunStatus
|
|
20
|
+
from dirigent_common import JsonMap
|
|
21
|
+
from dirigent_core.engine.context import ConnectionRecord
|
|
22
|
+
from dirigent_core.engine.definition import StepDefinition
|
|
23
|
+
from dirigent_core.models import Run, StepAttempt
|
|
24
|
+
from dirigent_plugin import RemoteHandle
|
|
25
|
+
|
|
26
|
+
MAX_SETTLED_PER_CLAIM = 50
|
|
27
|
+
|
|
28
|
+
CLAIMABLE_RUN_STATUSES = (RunStatus.QUEUED, RunStatus.RUNNING)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClaimedUnit(BaseModel):
|
|
32
|
+
"""Everything one block call needs, snapshotted by the transaction that claimed it."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True)
|
|
35
|
+
|
|
36
|
+
attempt_id: UUID
|
|
37
|
+
run_id: UUID
|
|
38
|
+
run_item_id: UUID | None
|
|
39
|
+
step_name: str
|
|
40
|
+
block_id: str
|
|
41
|
+
attempt_number: int
|
|
42
|
+
started_at: datetime
|
|
43
|
+
"""When this attempt first started, which a wait measures itself from."""
|
|
44
|
+
|
|
45
|
+
previous_status: AttemptStatus
|
|
46
|
+
step: StepDefinition
|
|
47
|
+
config: JsonMap
|
|
48
|
+
params: JsonMap
|
|
49
|
+
log_levels: JsonMap | None = None
|
|
50
|
+
"""The run's kept-level map, resolved against the block id when the buffer is built."""
|
|
51
|
+
scratch: str
|
|
52
|
+
item: JsonValue
|
|
53
|
+
has_item: bool
|
|
54
|
+
connections: dict[str, ConnectionRecord]
|
|
55
|
+
schemas: dict[str, JsonMap] = Field(default_factory=dict)
|
|
56
|
+
traceparent: str | None = None
|
|
57
|
+
"""The run's trace context, so an attempt's span joins the trace the run started in."""
|
|
58
|
+
|
|
59
|
+
remote_handle: RemoteHandle | None = None
|
|
60
|
+
poke_cursor: JsonMap | None = None
|
|
61
|
+
"""The cursor the last committed poke returned, handed to the next one."""
|
|
62
|
+
|
|
63
|
+
deadline_at: datetime | None = None
|
|
64
|
+
gone_probes: int = 0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def is_probe(self) -> bool:
|
|
68
|
+
"""Report whether this claim continues a wait rather than starting the work."""
|
|
69
|
+
return self.previous_status is AttemptStatus.WAITING
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_postgres(session: AsyncSession) -> bool:
|
|
73
|
+
"""Report whether this session speaks PostgreSQL."""
|
|
74
|
+
return session.get_bind().dialect.name == "postgresql"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def routable(tags: Sequence[str], *, postgres: bool) -> sa.ColumnElement[bool]:
|
|
78
|
+
"""Build the test that a run's pinned tags are all carried by a worker holding ``tags``.
|
|
79
|
+
|
|
80
|
+
A run pinning no tags is claimable by anyone. On PostgreSQL the column is ``jsonb`` and
|
|
81
|
+
the test is containment, which a GIN index answers; on SQLite it is a correlated
|
|
82
|
+
``json_each`` walk looking for one pinned tag the worker does not carry.
|
|
83
|
+
"""
|
|
84
|
+
carried = sorted(set(tags))
|
|
85
|
+
if postgres:
|
|
86
|
+
contained = sa.type_coerce(Run.worker_tags, JSONB).contained_by(
|
|
87
|
+
sa.cast(sa.literal(json.dumps(carried), sa.Text), JSONB)
|
|
88
|
+
)
|
|
89
|
+
return cast("sa.ColumnElement[bool]", contained)
|
|
90
|
+
element = sa.func.json_each(Run.worker_tags).table_valued("value", joins_implicitly=True)
|
|
91
|
+
missing = sa.select(1).select_from(element).where(element.c.value.notin_(carried))
|
|
92
|
+
return ~sa.exists(missing)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def priority_rank() -> sa.Case[int]:
|
|
96
|
+
"""Score a run's priority so the claim can sort on it, highest first."""
|
|
97
|
+
return sa.case(
|
|
98
|
+
{priority.value: rank for priority, rank in PRIORITY_RANK.items()},
|
|
99
|
+
value=Run.priority,
|
|
100
|
+
else_=PRIORITY_RANK[RunPriority.NORMAL],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def due_attempt_statement(
|
|
105
|
+
now: datetime, tags: Sequence[str] = (), *, postgres: bool = False
|
|
106
|
+
) -> sa.Select[tuple[StepAttempt]]:
|
|
107
|
+
"""Build the select that finds the next due unit of work this worker may run.
|
|
108
|
+
|
|
109
|
+
The order is priority first, then fairness, then due time. Fairness is round-robin
|
|
110
|
+
between runs: every attempt of a run still in flight is numbered within that run, and the
|
|
111
|
+
claim takes the lowest number among the due ones. The number counts the attempts already
|
|
112
|
+
taken, so a run that has been served falls behind one that has not, and a
|
|
113
|
+
four-hundred-item fan-out interleaves one attempt at a time with a two-step run rather
|
|
114
|
+
than holding every slot until it drains. Numbering only the due rows would not do it:
|
|
115
|
+
claiming one removes it, and the rest would renumber from one after every claim.
|
|
116
|
+
|
|
117
|
+
The subquery computes the ranking and nothing else. Every predicate that decides whether
|
|
118
|
+
an attempt may be claimed is repeated on the locked ``step_attempts`` relation in the
|
|
119
|
+
outer select, because ``SKIP LOCKED`` re-checks a row that changed under it against the
|
|
120
|
+
outer query's own predicates only: a status test living solely in the subquery would not
|
|
121
|
+
be re-evaluated, and two workers would take the same attempt.
|
|
122
|
+
"""
|
|
123
|
+
due_at = sa.func.coalesce(StepAttempt.available_at, StepAttempt.next_poll_at)
|
|
124
|
+
ranked = (
|
|
125
|
+
sa.select(
|
|
126
|
+
StepAttempt.id.label("attempt_id"),
|
|
127
|
+
priority_rank().label("priority_rank"),
|
|
128
|
+
sa.func.row_number().over(partition_by=StepAttempt.run_id, order_by=(due_at, StepAttempt.id)).label("turn"),
|
|
129
|
+
due_at.label("due_at"),
|
|
130
|
+
)
|
|
131
|
+
.join(Run, Run.id == StepAttempt.run_id)
|
|
132
|
+
.where(Run.status.in_(CLAIMABLE_RUN_STATUSES), routable(tags, postgres=postgres))
|
|
133
|
+
.subquery()
|
|
134
|
+
)
|
|
135
|
+
due = sa.or_(
|
|
136
|
+
sa.and_(StepAttempt.status == AttemptStatus.QUEUED, StepAttempt.available_at <= now),
|
|
137
|
+
sa.and_(StepAttempt.status == AttemptStatus.WAITING, StepAttempt.next_poll_at <= now),
|
|
138
|
+
)
|
|
139
|
+
return (
|
|
140
|
+
sa.select(StepAttempt)
|
|
141
|
+
.join(Run, Run.id == StepAttempt.run_id)
|
|
142
|
+
.join(ranked, ranked.c.attempt_id == StepAttempt.id)
|
|
143
|
+
.where(due, Run.status.in_(CLAIMABLE_RUN_STATUSES), routable(tags, postgres=postgres))
|
|
144
|
+
.order_by(ranked.c.priority_rank.desc(), ranked.c.turn, ranked.c.due_at, ranked.c.attempt_id)
|
|
145
|
+
.limit(1)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def select_due(session: AsyncSession, now: datetime, tags: Sequence[str] = ()) -> StepAttempt | None:
|
|
150
|
+
"""Run the claim select, locking the row on the dialect that can."""
|
|
151
|
+
postgres = is_postgres(session)
|
|
152
|
+
statement = due_attempt_statement(now, tags, postgres=postgres)
|
|
153
|
+
if postgres:
|
|
154
|
+
statement = statement.with_for_update(skip_locked=True, of=StepAttempt)
|
|
155
|
+
result = await session.execute(statement)
|
|
156
|
+
return result.scalars().first()
|