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.
Files changed (53) hide show
  1. dirigent_core/__init__.py +26 -0
  2. dirigent_core/alembic/env.py +71 -0
  3. dirigent_core/alembic/script.py.mako +26 -0
  4. dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
  5. dirigent_core/alerting.py +805 -0
  6. dirigent_core/artifacts.py +73 -0
  7. dirigent_core/auth.py +529 -0
  8. dirigent_core/blockdocs.py +223 -0
  9. dirigent_core/config.py +421 -0
  10. dirigent_core/configdocs.py +134 -0
  11. dirigent_core/database.py +177 -0
  12. dirigent_core/directory.py +339 -0
  13. dirigent_core/documents.py +878 -0
  14. dirigent_core/documentschema.py +92 -0
  15. dirigent_core/engine/__init__.py +120 -0
  16. dirigent_core/engine/claim.py +156 -0
  17. dirigent_core/engine/context.py +420 -0
  18. dirigent_core/engine/definition.py +543 -0
  19. dirigent_core/engine/executor.py +1029 -0
  20. dirigent_core/engine/failure.py +71 -0
  21. dirigent_core/engine/recovery.py +137 -0
  22. dirigent_core/engine/references.py +239 -0
  23. dirigent_core/engine/runs.py +865 -0
  24. dirigent_core/engine/services.py +74 -0
  25. dirigent_core/engine/state.py +434 -0
  26. dirigent_core/ids.py +39 -0
  27. dirigent_core/logging.py +310 -0
  28. dirigent_core/migrations.py +98 -0
  29. dirigent_core/models.py +626 -0
  30. dirigent_core/pipelines.py +494 -0
  31. dirigent_core/plugins.py +255 -0
  32. dirigent_core/protocol.py +276 -0
  33. dirigent_core/py.typed +0 -0
  34. dirigent_core/ratelimit.py +42 -0
  35. dirigent_core/registry.py +32 -0
  36. dirigent_core/retention.py +293 -0
  37. dirigent_core/scheduler.py +491 -0
  38. dirigent_core/schemas.py +147 -0
  39. dirigent_core/secrets.py +164 -0
  40. dirigent_core/storage.py +349 -0
  41. dirigent_core/telemetry.py +402 -0
  42. dirigent_core/trigger_documents.py +193 -0
  43. dirigent_core/triggers/__init__.py +117 -0
  44. dirigent_core/triggers/backfill.py +131 -0
  45. dirigent_core/triggers/materialize.py +218 -0
  46. dirigent_core/triggers/schedules.py +531 -0
  47. dirigent_core/triggers/webhooks.py +586 -0
  48. dirigent_core/types.py +59 -0
  49. dirigent_core/worker.py +350 -0
  50. dirigent_core-0.9.0.dist-info/METADATA +29 -0
  51. dirigent_core-0.9.0.dist-info/RECORD +53 -0
  52. dirigent_core-0.9.0.dist-info/WHEEL +4 -0
  53. 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()