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,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)
|