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,402 @@
1
+ """OpenTelemetry spans and metrics, off until an exporter is configured.
2
+
3
+ ``opentelemetry-api`` is always imported; without an SDK its providers are the API's own
4
+ no-op implementations. The SDK is wired up only when the standard ``OTEL_*`` environment
5
+ says where to send data.
6
+ """
7
+
8
+ import os
9
+ import socket
10
+ from collections.abc import Callable, Generator, Iterable, Mapping
11
+ from contextlib import contextmanager
12
+ from typing import Any, Final
13
+
14
+ from opentelemetry import metrics, trace
15
+ from opentelemetry.context import Context
16
+ from opentelemetry.metrics import CallbackOptions, Observation
17
+ from opentelemetry.trace import Span, SpanKind, Status, StatusCode
18
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
19
+
20
+ from dirigent_core.config import Settings
21
+ from dirigent_core.logging import get_logger, redact_path
22
+
23
+ SCOPE: Final = "dirigent"
24
+
25
+ #: The environment variables that mean "an exporter is configured", per the OTel spec.
26
+ EXPORTER_ENV: Final = (
27
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
28
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
29
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
30
+ "OTEL_TRACES_EXPORTER",
31
+ "OTEL_METRICS_EXPORTER",
32
+ )
33
+
34
+ #: Exporter values that mean "explicitly off", which the spec defines as valid settings.
35
+ DISABLED_VALUES: Final = frozenset({"", "none", "None"})
36
+
37
+ #: Bucket edges for both duration histograms, in seconds. The SDK's own defaults start at
38
+ #: 0, 5, 10, which puts every step a pipeline actually runs in one bucket.
39
+ DURATION_BUCKETS: Final = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 300.0, 900.0)
40
+
41
+ #: How a trace context is written down and read back. A run carries one across processes,
42
+ #: so it has to survive a database column and an hour of waiting.
43
+ _propagator: Final = TraceContextTextMapPropagator()
44
+
45
+ _logger = get_logger("telemetry")
46
+ _tracer = trace.get_tracer(SCOPE)
47
+ _meter = metrics.get_meter(SCOPE)
48
+ _configured = False
49
+
50
+
51
+ def exporter_configured(environ: Mapping[str, str] | None = None) -> bool:
52
+ """Report whether the standard environment names somewhere to send telemetry."""
53
+ env = environ if environ is not None else os.environ
54
+ if env.get("OTEL_SDK_DISABLED", "").lower() == "true":
55
+ return False
56
+ return any(env.get(name) not in DISABLED_VALUES and env.get(name) is not None for name in EXPORTER_ENV)
57
+
58
+
59
+ def configure_telemetry(settings: Settings | None = None, *, environ: Mapping[str, str] | None = None) -> bool:
60
+ """Wire up the OpenTelemetry SDK, returning whether telemetry ended up live."""
61
+ global _configured
62
+ if _configured or not exporter_configured(environ):
63
+ return _configured
64
+ try:
65
+ _install_sdk(settings, environ if environ is not None else os.environ)
66
+ except Exception as error: # telemetry must never be the reason a process fails to start
67
+ _logger.warning("telemetry could not be configured", error=str(error))
68
+ return False
69
+ _configured = True
70
+ _logger.info("telemetry configured", service=SCOPE)
71
+ return True
72
+
73
+
74
+ def reset_telemetry() -> None:
75
+ """Forget that telemetry was configured, so a test can configure it differently."""
76
+ global _configured
77
+ _configured = False
78
+
79
+
80
+ def resource_attributes(settings: Settings | None, environ: Mapping[str, str]) -> dict[str, str]:
81
+ """Build the resource attributes every span and metric carries.
82
+
83
+ ``service.instance.id`` is what separates two processes of one service; the standard
84
+ resource environment wins where it already names one.
85
+ """
86
+ from dirigent_core import __version__
87
+
88
+ attributes = {
89
+ "service.name": environ.get("OTEL_SERVICE_NAME", SCOPE),
90
+ "service.version": __version__,
91
+ "deployment.environment": settings.environment if settings else "local",
92
+ }
93
+ declared = environ.get("OTEL_RESOURCE_ATTRIBUTES", "").split(",")
94
+ if not any(entry.split("=", 1)[0].strip() == "service.instance.id" for entry in declared):
95
+ attributes["service.instance.id"] = f"{socket.gethostname()}-{os.getpid()}"
96
+ return attributes
97
+
98
+
99
+ def _install_sdk(settings: Settings | None, environ: Mapping[str, str]) -> None:
100
+ """Build the tracer and meter providers from the standard environment."""
101
+ from opentelemetry.sdk.metrics import MeterProvider
102
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
103
+ from opentelemetry.sdk.resources import Resource
104
+ from opentelemetry.sdk.trace import TracerProvider
105
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
106
+
107
+ resource = Resource.create(resource_attributes(settings, environ))
108
+ tracer_provider = TracerProvider(resource=resource)
109
+ span_exporter = _span_exporter(environ)
110
+ if span_exporter is not None:
111
+ tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
112
+ trace.set_tracer_provider(tracer_provider)
113
+
114
+ metric_exporter = _metric_exporter(environ)
115
+ readers = [PeriodicExportingMetricReader(metric_exporter)] if metric_exporter is not None else []
116
+ metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=readers))
117
+
118
+ global _tracer, _meter
119
+ _tracer = trace.get_tracer(SCOPE)
120
+ _meter = metrics.get_meter(SCOPE)
121
+ register_instruments()
122
+
123
+
124
+ def _optional(module: str, name: str) -> Any:
125
+ """Import an exporter class by name, so the package that speaks the wire stays an extra."""
126
+ import importlib
127
+
128
+ return getattr(importlib.import_module(module), name)
129
+
130
+
131
+ def _span_exporter(environ: Mapping[str, str]) -> Any | None:
132
+ """Build the span exporter the environment names, or nothing when it names none."""
133
+ choice = environ.get("OTEL_TRACES_EXPORTER", "otlp")
134
+ if choice in DISABLED_VALUES:
135
+ return None
136
+ if choice == "console":
137
+ return _optional("opentelemetry.sdk.trace.export", "ConsoleSpanExporter")()
138
+ return _optional("opentelemetry.exporter.otlp.proto.http.trace_exporter", "OTLPSpanExporter")()
139
+
140
+
141
+ def _metric_exporter(environ: Mapping[str, str]) -> Any | None:
142
+ """Build the metric exporter the environment names, or nothing when it names none."""
143
+ choice = environ.get("OTEL_METRICS_EXPORTER", "otlp")
144
+ if choice in DISABLED_VALUES:
145
+ return None
146
+ if choice == "console":
147
+ return _optional("opentelemetry.sdk.metrics.export", "ConsoleMetricExporter")()
148
+ return _optional("opentelemetry.exporter.otlp.proto.http.metric_exporter", "OTLPMetricExporter")()
149
+
150
+
151
+ class Gauges:
152
+ """The numbers an operator watches that are levels rather than events."""
153
+
154
+ def __init__(self) -> None:
155
+ """Start every level at zero."""
156
+ self.queue_depth = 0
157
+ self.waiting = 0
158
+ self.in_flight = 0
159
+ self.scheduler_lag = 0.0
160
+ self.heartbeat_ages: dict[str, float] = {}
161
+ self.worker: str | None = None
162
+
163
+ def observe(
164
+ self,
165
+ *,
166
+ queue_depth: int | None = None,
167
+ waiting: int | None = None,
168
+ scheduler_lag: float | None = None,
169
+ heartbeat_ages: Mapping[str, float] | None = None,
170
+ ) -> None:
171
+ """Record what the engine last saw."""
172
+ if queue_depth is not None:
173
+ self.queue_depth = queue_depth
174
+ if waiting is not None:
175
+ self.waiting = waiting
176
+ if scheduler_lag is not None:
177
+ self.scheduler_lag = scheduler_lag
178
+ if heartbeat_ages is not None:
179
+ self.heartbeat_ages = dict(heartbeat_ages)
180
+
181
+
182
+ gauges = Gauges()
183
+
184
+ _runs = _meter.create_counter("dirigent.runs", description="Runs by terminal status.")
185
+ _steps = _meter.create_counter("dirigent.steps", description="Step attempts by terminal status.")
186
+ _step_duration = _meter.create_histogram(
187
+ "dirigent.step.duration",
188
+ unit="s",
189
+ description="How long a step attempt took.",
190
+ explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
191
+ )
192
+ _block_duration = _meter.create_histogram(
193
+ "dirigent.block.duration",
194
+ unit="s",
195
+ description="How long one block call took.",
196
+ explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
197
+ )
198
+
199
+
200
+ def observe_queue_depth(options: CallbackOptions) -> Iterable[Observation]:
201
+ """Report how many claimable units this process last saw."""
202
+ return [Observation(gauges.queue_depth)]
203
+
204
+
205
+ def observe_waiting(options: CallbackOptions) -> Iterable[Observation]:
206
+ """Report how many attempts this process last saw parked rather than running."""
207
+ return [Observation(gauges.waiting)]
208
+
209
+
210
+ def observe_in_flight(options: CallbackOptions) -> Iterable[Observation]:
211
+ """Report how many block calls this worker is running right now.
212
+
213
+ A process with no worker in it reports nothing rather than a zero under no name.
214
+ """
215
+ if gauges.worker is None:
216
+ return []
217
+ return [Observation(gauges.in_flight, {"dirigent.worker": gauges.worker})]
218
+
219
+
220
+ def observe_scheduler_lag(options: CallbackOptions) -> Iterable[Observation]:
221
+ """Report how late the oldest due schedule was at this process's last tick."""
222
+ return [Observation(gauges.scheduler_lag)]
223
+
224
+
225
+ def observe_heartbeat_age(options: CallbackOptions) -> Iterable[Observation]:
226
+ """Report how long ago each registered worker last reported in."""
227
+ return [Observation(age, {"dirigent.worker": name}) for name, age in gauges.heartbeat_ages.items()]
228
+
229
+
230
+ def register_instruments() -> None:
231
+ """Rebuild the instruments against the configured meter, once the SDK is installed."""
232
+ global _runs, _steps, _step_duration, _block_duration
233
+ _runs = _meter.create_counter("dirigent.runs", description="Runs by terminal status.")
234
+ _steps = _meter.create_counter("dirigent.steps", description="Step attempts by terminal status.")
235
+ _step_duration = _meter.create_histogram(
236
+ "dirigent.step.duration",
237
+ unit="s",
238
+ description="How long a step attempt took.",
239
+ explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
240
+ )
241
+ _block_duration = _meter.create_histogram(
242
+ "dirigent.block.duration",
243
+ unit="s",
244
+ description="How long one block call took.",
245
+ explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
246
+ )
247
+ _meter.create_observable_gauge(
248
+ "dirigent.queue.depth", callbacks=[observe_queue_depth], description="Claimable units of work."
249
+ )
250
+ _meter.create_observable_gauge(
251
+ "dirigent.waiting", callbacks=[observe_waiting], description="Attempts parked, not running on a worker."
252
+ )
253
+ _meter.create_observable_gauge(
254
+ "dirigent.worker.in_flight", callbacks=[observe_in_flight], description="Block calls running now."
255
+ )
256
+ _meter.create_observable_gauge(
257
+ "dirigent.scheduler.lag",
258
+ unit="s",
259
+ callbacks=[observe_scheduler_lag],
260
+ description="Seconds between the oldest due schedule's due time and the tick that fires it.",
261
+ )
262
+ _meter.create_observable_gauge(
263
+ "dirigent.worker.heartbeat_age",
264
+ unit="s",
265
+ callbacks=[observe_heartbeat_age],
266
+ description="Seconds since each registered worker last reported in.",
267
+ )
268
+
269
+
270
+ def record_run(status: str, pipeline: str) -> None:
271
+ """Count one run reaching a terminal status."""
272
+ _runs.add(1, {"dirigent.run.status": status, "dirigent.pipeline": pipeline})
273
+
274
+
275
+ def record_step(status: str, block: str, seconds: float | None = None) -> None:
276
+ """Count one step attempt settling, and how long it took."""
277
+ attributes = {"dirigent.step.status": status, "dirigent.block": block}
278
+ _steps.add(1, attributes)
279
+ if seconds is not None:
280
+ _step_duration.record(seconds, attributes)
281
+
282
+
283
+ def record_block_call(block: str, call: str, seconds: float) -> None:
284
+ """Record how long one block call took."""
285
+ _block_duration.record(seconds, {"dirigent.block": block, "dirigent.block.call": call})
286
+
287
+
288
+ @contextmanager
289
+ def run_span(pipeline: str, *, trigger: str | None = None) -> Generator[Span]:
290
+ """Open the root span of a run: creating it, and the parent of every attempt of it.
291
+
292
+ The span covers the creation, not the run. A run outlives the process that started it
293
+ and executes on workers that were not there yet, so what joins them is the traceparent
294
+ this span is captured as. Its children therefore end long after it does.
295
+ """
296
+ with _tracer.start_as_current_span(
297
+ f"run {pipeline}",
298
+ kind=SpanKind.PRODUCER,
299
+ attributes={"dirigent.pipeline": pipeline, "dirigent.run.trigger": trigger or "unknown"},
300
+ ) as span:
301
+ yield span
302
+
303
+
304
+ @contextmanager
305
+ def attempt_span(*, run_id: str, step: str, block: str, attempt: int, parent: Context | None = None) -> Generator[Span]:
306
+ """Open the span covering one step attempt, from claim to settled outcome.
307
+
308
+ ``parent`` is the run's own trace context, read back from the run row, which is what
309
+ puts an attempt run on a worker into the trace of whatever asked for the run.
310
+ """
311
+ with _tracer.start_as_current_span(
312
+ f"step {step}",
313
+ context=parent,
314
+ kind=SpanKind.CONSUMER,
315
+ attributes={
316
+ "dirigent.run.id": run_id,
317
+ "dirigent.step": step,
318
+ "dirigent.block": block,
319
+ "dirigent.attempt": attempt,
320
+ },
321
+ ) as span:
322
+ yield span
323
+
324
+
325
+ @contextmanager
326
+ def block_span(block: str, call: str) -> Generator[Span]:
327
+ """Open the span covering one block call: execute, probe, fetch, or cancel."""
328
+ with _tracer.start_as_current_span(
329
+ f"{block}.{call}",
330
+ kind=SpanKind.CLIENT,
331
+ attributes={"dirigent.block": block, "dirigent.block.call": call},
332
+ ) as span:
333
+ yield span
334
+
335
+
336
+ def record_failure(span: Span, error: BaseException | str) -> None:
337
+ """Mark a span as failed, with the message an operator would want in the trace."""
338
+ if isinstance(error, BaseException):
339
+ span.record_exception(error)
340
+ span.set_status(Status(StatusCode.ERROR, str(error)))
341
+ else:
342
+ span.set_status(Status(StatusCode.ERROR, error))
343
+
344
+
345
+ def current_traceparent() -> str | None:
346
+ """Write the active span down as a W3C traceparent, for a run row to carry.
347
+
348
+ Returns nothing when no exporter is configured, because the no-op span's all-zero ids
349
+ name a trace that does not exist and would parent every attempt onto nothing.
350
+ """
351
+ carrier: dict[str, str] = {}
352
+ _propagator.inject(carrier)
353
+ return carrier.get("traceparent")
354
+
355
+
356
+ def context_from(traceparent: str | None) -> Context | None:
357
+ """Read a stored traceparent back into a context a span can be opened under."""
358
+ if not traceparent:
359
+ return None
360
+ context = _propagator.extract({"traceparent": traceparent})
361
+ return context if trace.get_current_span(context).get_span_context().is_valid else None
362
+
363
+
364
+ def trace_id_of(traceparent: str | None) -> str | None:
365
+ """Read the trace out of a traceparent, which is what deep-links to a trace viewer.
366
+
367
+ A context that does not read back names no trace, all-zero ids included: they would
368
+ deep-link to a trace that does not exist.
369
+ """
370
+ context = context_from(traceparent)
371
+ if context is None:
372
+ return None
373
+ return format(trace.get_current_span(context).get_span_context().trace_id, "032x")
374
+
375
+
376
+ def instrument_fastapi(app: Any) -> None:
377
+ """Give every HTTP request a span, without depending on an instrumentation package."""
378
+
379
+ async def span_per_request(request: Any, call_next: Callable[[Any], Any]) -> Any:
380
+ """Wrap one request in a span named after its route.
381
+
382
+ The span is named and attributed from a redacted path, never the raw one: a delivery
383
+ to ``POST /hooks/<token>`` carries its whole credential in the path, and a span name
384
+ travels to every trace viewer the operator has.
385
+ """
386
+ path = redact_path(request.url.path)
387
+ with _tracer.start_as_current_span(
388
+ f"{request.method} {path}",
389
+ kind=SpanKind.SERVER,
390
+ attributes={"http.request.method": request.method, "url.path": path},
391
+ ) as span:
392
+ response = await call_next(request)
393
+ template = getattr(request.scope.get("route"), "path", None)
394
+ if isinstance(template, str):
395
+ span.set_attribute("http.route", template)
396
+ span.update_name(f"{request.method} {template}")
397
+ span.set_attribute("http.response.status_code", response.status_code)
398
+ if response.status_code >= 500:
399
+ record_failure(span, f"HTTP {response.status_code}")
400
+ return response
401
+
402
+ app.middleware("http")(span_per_request)
@@ -0,0 +1,193 @@
1
+ """Applying, reading, and deleting a ``kind: triggers`` document.
2
+
3
+ The document names one pipeline and declares clocks and webhooks for it. It has no versions:
4
+ what it says is operational, and the digest is only what makes an unchanged re-apply cheap.
5
+ An unchanged one still reconciles, exactly as a pipeline document does.
6
+ """
7
+
8
+ from typing import Final
9
+ from uuid import UUID
10
+
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from dirigent_client.enums import DocumentKind, ProvenanceSource
15
+ from dirigent_client.schemas import ApplyResult, PipelinePlan, PlanAction
16
+ from dirigent_core.documents import TriggerTarget, digest_of, validate_against_catalog
17
+ from dirigent_core.engine.definition import TriggersDefinition, canonical_document, load_definition
18
+ from dirigent_core.engine.runs import Provenance
19
+ from dirigent_core.engine.services import EngineServices
20
+ from dirigent_core.engine.state import lock_pipeline
21
+ from dirigent_core.logging import get_logger
22
+ from dirigent_core.models import Pipeline, Schedule, TriggerDocument, WebhookTrigger
23
+ from dirigent_core.triggers.materialize import materialize_triggers, owner_issues
24
+
25
+ _logger = get_logger("triggers")
26
+
27
+ #: The owner a document that does not exist yet declares under: an id no row can hold, so
28
+ #: every code the pipeline already carries belongs to somebody else.
29
+ UNAPPLIED: Final = UUID(int=0)
30
+
31
+
32
+ async def find_trigger_document(session: AsyncSession, code: str) -> TriggerDocument | None:
33
+ """Find the triggers document holding a code."""
34
+ found = await session.execute(sa.select(TriggerDocument).where(TriggerDocument.code == code))
35
+ return found.scalar_one_or_none()
36
+
37
+
38
+ async def list_trigger_documents(
39
+ session: AsyncSession,
40
+ *,
41
+ after: str | None = None,
42
+ limit: int | None = None,
43
+ ) -> list[TriggerDocument]:
44
+ """List triggers documents in code order."""
45
+ statement = sa.select(TriggerDocument).order_by(TriggerDocument.code)
46
+ if after is not None:
47
+ statement = statement.where(TriggerDocument.code > after)
48
+ if limit is not None:
49
+ statement = statement.limit(limit)
50
+ rows = await session.execute(statement)
51
+ return list(rows.scalars())
52
+
53
+
54
+ async def owned_codes(session: AsyncSession, document_id: UUID) -> tuple[list[str], list[str]]:
55
+ """List the schedules and the webhooks one triggers document owns, in code order."""
56
+ schedules = await session.execute(
57
+ sa.select(Schedule.code).where(Schedule.trigger_document_id == document_id).order_by(Schedule.code)
58
+ )
59
+ webhooks = await session.execute(
60
+ sa.select(WebhookTrigger.code)
61
+ .where(WebhookTrigger.trigger_document_id == document_id)
62
+ .order_by(WebhookTrigger.code)
63
+ )
64
+ return list(schedules.scalars()), list(webhooks.scalars())
65
+
66
+
67
+ async def delete_trigger_document(session: AsyncSession, row: TriggerDocument) -> None:
68
+ """Delete a triggers document, and with it every schedule and webhook it owns."""
69
+ await session.delete(row)
70
+ await session.flush()
71
+ _logger.info("triggers document deleted", document=row.code)
72
+
73
+
74
+ async def delete_absent_trigger_documents(session: AsyncSession, keep: set[str], *, dry_run: bool = False) -> list[str]:
75
+ """Delete every directory-provenance triggers document whose code is not in ``keep``.
76
+
77
+ A pipeline is deactivated rather than deleted because its history is worth keeping. A
78
+ triggers document has none: it is deleted, and its rows go with it.
79
+ """
80
+ rows = await session.execute(
81
+ sa.select(TriggerDocument)
82
+ .where(
83
+ TriggerDocument.provenance_source == ProvenanceSource.DIRECTORY,
84
+ TriggerDocument.code.notin_(keep) if keep else sa.true(),
85
+ )
86
+ .order_by(TriggerDocument.code)
87
+ )
88
+ absent = list(rows.scalars())
89
+ if dry_run:
90
+ return [row.code for row in absent]
91
+ for row in absent:
92
+ await delete_trigger_document(session, row)
93
+ return [row.code for row in absent]
94
+
95
+
96
+ async def plan_triggers_apply(
97
+ session: AsyncSession,
98
+ services: EngineServices,
99
+ definition: TriggersDefinition,
100
+ pipeline: Pipeline | None,
101
+ existing: TriggerDocument | None,
102
+ ) -> PipelinePlan:
103
+ """Work out what applying a triggers document would do, without writing anything."""
104
+ digest = digest_of(definition)
105
+ issues = validate_against_catalog(
106
+ definition,
107
+ services.host.catalog(),
108
+ format_checker=services.format_checker,
109
+ target=await _target(session, pipeline),
110
+ )
111
+ if not issues and pipeline is not None:
112
+ issues = await owner_issues(
113
+ session,
114
+ pipeline,
115
+ definition.triggers,
116
+ owner=existing.id if existing is not None else UNAPPLIED,
117
+ )
118
+ if issues:
119
+ return PipelinePlan(
120
+ code=definition.code,
121
+ pipeline=definition.pipeline,
122
+ action=PlanAction.INVALID,
123
+ digest=digest,
124
+ issues=issues,
125
+ )
126
+ if existing is None:
127
+ return PipelinePlan(code=definition.code, pipeline=definition.pipeline, action=PlanAction.CREATE, digest=digest)
128
+ action = PlanAction.UNCHANGED if existing.digest == digest else PlanAction.UPDATE
129
+ return PipelinePlan(code=definition.code, pipeline=definition.pipeline, action=action, digest=digest)
130
+
131
+
132
+ async def _target(session: AsyncSession, pipeline: Pipeline | None) -> TriggerTarget | None:
133
+ """Read the pipeline a triggers document names, as the instance holds it now."""
134
+ if pipeline is None:
135
+ return None
136
+ current = None
137
+ if pipeline.current_version is not None:
138
+ from dirigent_core.pipelines import get_version
139
+
140
+ current = load_definition((await get_version(session, pipeline)).document)
141
+ return TriggerTarget(code=pipeline.code, active=pipeline.active, definition=current)
142
+
143
+
144
+ async def apply_triggers_document(
145
+ session: AsyncSession,
146
+ services: EngineServices,
147
+ definition: TriggersDefinition,
148
+ *,
149
+ provenance: Provenance | None = None,
150
+ dry_run: bool = False,
151
+ pause_schedules: bool = False,
152
+ ) -> ApplyResult:
153
+ """Validate, plan, and -- unless this is a dry run -- write the document and its rows.
154
+
155
+ An unchanged document still reconciles, so a schedule deleted by hand comes back.
156
+ """
157
+ from dirigent_core.pipelines import find_pipeline
158
+
159
+ pipeline = await find_pipeline(session, definition.pipeline)
160
+ if pipeline is not None:
161
+ # The plan reads the pipeline's rows and everything after it writes against that read.
162
+ await lock_pipeline(session, pipeline.id)
163
+ existing = await find_trigger_document(session, definition.code)
164
+ plan = await plan_triggers_apply(session, services, definition, pipeline, existing)
165
+ if pipeline is None or dry_run or plan.action is PlanAction.INVALID:
166
+ return ApplyResult(
167
+ kind=DocumentKind.TRIGGERS,
168
+ plan=plan,
169
+ pipeline_id=pipeline.id if pipeline is not None else None,
170
+ dry_run=dry_run,
171
+ )
172
+ row = existing or TriggerDocument(code=definition.code)
173
+ row.name = definition.name
174
+ row.description = definition.description
175
+ row.pipeline_id = pipeline.id
176
+ row.document = canonical_document(definition)
177
+ row.digest = plan.digest
178
+ if provenance is not None:
179
+ row.provenance_source = provenance.source
180
+ row.provenance_ref = provenance.ref
181
+ row.applied_by = provenance.applied_by
182
+ if existing is None:
183
+ session.add(row)
184
+ await session.flush()
185
+ triggers = await materialize_triggers(session, pipeline, definition, pause_created=pause_schedules, owner=row.id)
186
+ _logger.info(
187
+ "triggers document applied",
188
+ document=definition.code,
189
+ pipeline=pipeline.code,
190
+ action=plan.action.value,
191
+ digest=plan.digest,
192
+ )
193
+ return ApplyResult(kind=DocumentKind.TRIGGERS, plan=plan, pipeline_id=pipeline.id, triggers=triggers)