dirigent-cli 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_cli/__init__.py +5 -0
- dirigent_cli/aliases.py +41 -0
- dirigent_cli/commands.py +2525 -0
- dirigent_cli/context.py +136 -0
- dirigent_cli/formatters.py +158 -0
- dirigent_cli/graph.py +109 -0
- dirigent_cli/health.py +294 -0
- dirigent_cli/local.py +790 -0
- dirigent_cli/main.py +1169 -0
- dirigent_cli/output.py +543 -0
- dirigent_cli/params.py +389 -0
- dirigent_cli/profiles.py +221 -0
- dirigent_cli/project.py +643 -0
- dirigent_cli/py.typed +0 -0
- dirigent_cli/reaper.py +115 -0
- dirigent_cli/scaffold.py +63 -0
- dirigent_cli/schemas.py +85 -0
- dirigent_cli/sources.py +76 -0
- dirigent_cli/stream.py +180 -0
- dirigent_cli/summaries.py +420 -0
- dirigent_cli/templates/pack/README.md.tmpl +23 -0
- dirigent_cli/templates/pack/__init__.py.tmpl +24 -0
- dirigent_cli/templates/pack/operator.py.tmpl +34 -0
- dirigent_cli/templates/pack/pyproject.toml.tmpl +21 -0
- dirigent_cli/templates/pack/test_plugin.py.tmpl +21 -0
- dirigent_cli/timing.py +322 -0
- dirigent_cli/triggers.py +631 -0
- dirigent_cli-0.9.0.dist-info/METADATA +24 -0
- dirigent_cli-0.9.0.dist-info/RECORD +32 -0
- dirigent_cli-0.9.0.dist-info/WHEEL +4 -0
- dirigent_cli-0.9.0.dist-info/entry_points.txt +4 -0
- dirigent_cli-0.9.0.dist-info/licenses/LICENSE +18 -0
dirigent_cli/timing.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Where a run's wall clock went: queued, running and waiting, per attempt and per step.
|
|
2
|
+
|
|
3
|
+
Every number here is read off the attempt rows the server already keeps, so a profile says
|
|
4
|
+
what the timestamps prove and nothing else. An attempt spends its life in three states a
|
|
5
|
+
reader can tell apart. It is QUEUED once its upstream is done and it is claimable -- which
|
|
6
|
+
is what ``available_at`` marks -- until a worker takes it up, so queued time is workers
|
|
7
|
+
being busy and nothing else. It is WAITING while the engine deliberately parks it: a
|
|
8
|
+
retry's backoff before it may be claimed at all, and the interval between a sensor's
|
|
9
|
+
probes. It is RUNNING while a call is in flight. An attempt that never started spent no
|
|
10
|
+
time of its own: what it sat through belongs to the steps above it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
from dirigent_client.enums import AttemptStatus
|
|
20
|
+
from dirigent_client.schemas import AttemptOut, DagNode, RunDetail
|
|
21
|
+
|
|
22
|
+
#: What an unfinished step sorts as, so an aware moment is never compared with a naive one.
|
|
23
|
+
UNSETTLED: Final = datetime.min.replace(tzinfo=UTC)
|
|
24
|
+
|
|
25
|
+
#: How often an attempt has to have parked before the gap between its probes is a cadence
|
|
26
|
+
#: rather than one interval that happened once.
|
|
27
|
+
MIN_PROBES: Final = 2
|
|
28
|
+
|
|
29
|
+
#: Waiting this many times the work it waited on is a probe cadence set for a slower world.
|
|
30
|
+
IDLE_PROBE_RATIO: Final = 10
|
|
31
|
+
|
|
32
|
+
#: A deadline this many times the wait it needed was guessed rather than measured.
|
|
33
|
+
SLACK_DEADLINE_RATIO: Final = 20
|
|
34
|
+
|
|
35
|
+
#: How many elements a fan-out needs before running them one after another is worth saying.
|
|
36
|
+
SERIAL_FAN_OUT_MIN: Final = 2
|
|
37
|
+
|
|
38
|
+
#: What an attempt settled as when nothing of its own ended it: the deadline or a cancel
|
|
39
|
+
#: reached it while it was parked, so its last probe is not what its final span measures.
|
|
40
|
+
PARKED_TO_THE_END: Final = frozenset({AttemptStatus.SKIPPED, AttemptStatus.CANCELLED})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AttemptTiming(BaseModel):
|
|
44
|
+
"""Where one attempt's wall clock went."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(frozen=True)
|
|
47
|
+
|
|
48
|
+
queued_ms: int = 0
|
|
49
|
+
"""From becoming claimable until a worker took it up, which is workers being busy."""
|
|
50
|
+
|
|
51
|
+
running_ms: int = 0
|
|
52
|
+
"""The call itself, which on an attempt that parked is the probe that settled it."""
|
|
53
|
+
|
|
54
|
+
waiting_ms: int = 0
|
|
55
|
+
"""Parked on purpose: a retry's backoff, and the intervals between a sensor's probes."""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def total_ms(self) -> int:
|
|
59
|
+
"""Add the three up, which is the attempt's own wall clock."""
|
|
60
|
+
return self.queued_ms + self.running_ms + self.waiting_ms
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StepTiming(BaseModel):
|
|
64
|
+
"""Where one step's wall clock went, and how many tries it took."""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(frozen=True)
|
|
67
|
+
|
|
68
|
+
step: str
|
|
69
|
+
block: str
|
|
70
|
+
attempts: int = 0
|
|
71
|
+
queued_ms: int = 0
|
|
72
|
+
running_ms: int = 0
|
|
73
|
+
waiting_ms: int = 0
|
|
74
|
+
finished_at: datetime | None = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def total_ms(self) -> int:
|
|
78
|
+
"""Add the three up, which is what this step put on the run's clock."""
|
|
79
|
+
return self.queued_ms + self.running_ms + self.waiting_ms
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProfileWarning(BaseModel):
|
|
83
|
+
"""One cause the timestamps prove, said as the sentence a reader acts on."""
|
|
84
|
+
|
|
85
|
+
model_config = ConfigDict(frozen=True)
|
|
86
|
+
|
|
87
|
+
step: str
|
|
88
|
+
cause: str
|
|
89
|
+
message: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RunProfile(BaseModel):
|
|
93
|
+
"""A run broken into where its wall clock went, along the chain that decided it."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(frozen=True)
|
|
96
|
+
|
|
97
|
+
run_id: str
|
|
98
|
+
pipeline: str
|
|
99
|
+
status: str
|
|
100
|
+
duration_ms: int | None = None
|
|
101
|
+
critical_path: list[str] = Field(default_factory=list[str])
|
|
102
|
+
queued_ms: int = 0
|
|
103
|
+
running_ms: int = 0
|
|
104
|
+
waiting_ms: int = 0
|
|
105
|
+
steps: list[StepTiming] = Field(default_factory=list[StepTiming])
|
|
106
|
+
warnings: list[ProfileWarning] = Field(default_factory=list[ProfileWarning])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ms(start: datetime | None, end: datetime | None) -> int:
|
|
110
|
+
"""Measure one span in milliseconds, and call anything that runs backwards nothing."""
|
|
111
|
+
if start is None or end is None:
|
|
112
|
+
return 0
|
|
113
|
+
return max(0, round((end - start).total_seconds() * 1000))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def attempt_timing(attempt: AttemptOut, *, at: datetime) -> AttemptTiming:
|
|
117
|
+
"""Split one attempt's wall clock into queued, running and waiting.
|
|
118
|
+
|
|
119
|
+
``available_at`` is when the attempt became claimable, so a retry's backoff is the span
|
|
120
|
+
before it and the queue is the span after it. An attempt that parked keeps its first
|
|
121
|
+
``started_at`` across every probe, and ``heartbeat_at`` is when the last probe took it
|
|
122
|
+
up: everything before that is waiting, and the probe that settled it is running.
|
|
123
|
+
"""
|
|
124
|
+
ready = attempt.available_at or attempt.created_at
|
|
125
|
+
# A first attempt is made claimable when the steps above it finish, and that span is
|
|
126
|
+
# their time rather than its own; only a retry is written down and then held back.
|
|
127
|
+
backoff_ms = _ms(attempt.created_at, attempt.available_at) if attempt.attempt > 1 else 0
|
|
128
|
+
started = attempt.started_at
|
|
129
|
+
if started is None:
|
|
130
|
+
if attempt.status is not AttemptStatus.QUEUED:
|
|
131
|
+
return AttemptTiming()
|
|
132
|
+
return AttemptTiming(queued_ms=_ms(ready, at), waiting_ms=backoff_ms)
|
|
133
|
+
queued_ms = _ms(ready, started)
|
|
134
|
+
if attempt.finished_at is None and attempt.status is AttemptStatus.WAITING:
|
|
135
|
+
return AttemptTiming(queued_ms=queued_ms, waiting_ms=backoff_ms + _ms(started, at))
|
|
136
|
+
end = attempt.finished_at or at
|
|
137
|
+
heartbeat = attempt.heartbeat_at
|
|
138
|
+
parked = attempt.poke_count >= 1 and heartbeat is not None and started <= heartbeat <= end
|
|
139
|
+
if parked and attempt.status in PARKED_TO_THE_END:
|
|
140
|
+
# A deadline or a cancel reached the attempt where it lay, so no probe was running
|
|
141
|
+
# when it settled and the whole span from its first probe on was a wait.
|
|
142
|
+
return AttemptTiming(queued_ms=queued_ms, waiting_ms=backoff_ms + _ms(started, end))
|
|
143
|
+
boundary = heartbeat if parked else started
|
|
144
|
+
return AttemptTiming(
|
|
145
|
+
queued_ms=queued_ms,
|
|
146
|
+
waiting_ms=backoff_ms + _ms(started, boundary),
|
|
147
|
+
running_ms=_ms(boundary, end),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _settled_at(attempt: AttemptOut) -> datetime | None:
|
|
152
|
+
"""Say when this attempt last moved, which is what orders one step against another."""
|
|
153
|
+
return attempt.finished_at or attempt.started_at
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _lineage(attempts: Sequence[AttemptOut]) -> list[AttemptOut]:
|
|
157
|
+
"""Take the attempts that decided when a step finished.
|
|
158
|
+
|
|
159
|
+
A retry runs after the attempt it retries, so one element's tries add up to wall clock;
|
|
160
|
+
a fan-out's elements run beside each other, so the element that finished last speaks for
|
|
161
|
+
the step and the others are not on its clock twice.
|
|
162
|
+
"""
|
|
163
|
+
if not attempts:
|
|
164
|
+
return []
|
|
165
|
+
last = max(attempts, key=lambda row: (_settled_at(row) is not None, _settled_at(row) or UNSETTLED))
|
|
166
|
+
return [row for row in attempts if row.run_item_id == last.run_item_id]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def step_timing(node: DagNode, attempts: Sequence[AttemptOut], *, at: datetime) -> StepTiming:
|
|
170
|
+
"""Fold one step's attempts into what the step put on the run's clock."""
|
|
171
|
+
lineage = _lineage(attempts)
|
|
172
|
+
timings = [attempt_timing(row, at=at) for row in lineage]
|
|
173
|
+
finished = [row.finished_at for row in attempts if row.finished_at is not None]
|
|
174
|
+
return StepTiming(
|
|
175
|
+
step=node.code,
|
|
176
|
+
block=node.block,
|
|
177
|
+
attempts=len(attempts),
|
|
178
|
+
queued_ms=sum(one.queued_ms for one in timings),
|
|
179
|
+
running_ms=sum(one.running_ms for one in timings),
|
|
180
|
+
waiting_ms=sum(one.waiting_ms for one in timings),
|
|
181
|
+
finished_at=max(finished) if finished else None,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def by_step(attempts: Iterable[AttemptOut]) -> dict[str, list[AttemptOut]]:
|
|
186
|
+
"""Group a run's attempts by the step they are tries of."""
|
|
187
|
+
grouped: dict[str, list[AttemptOut]] = {}
|
|
188
|
+
for attempt in attempts:
|
|
189
|
+
grouped.setdefault(attempt.step_name, []).append(attempt)
|
|
190
|
+
return grouped
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def critical_path(nodes: Sequence[DagNode], timings: Mapping[str, StepTiming]) -> list[str]:
|
|
194
|
+
"""Walk back from the step that finished last through whichever upstream held it up.
|
|
195
|
+
|
|
196
|
+
A step cannot start before its last upstream finished, so the chain of last-finishing
|
|
197
|
+
dependencies is the one that decided the run's wall clock.
|
|
198
|
+
"""
|
|
199
|
+
ran = {node.code: node for node in nodes if node.code in timings}
|
|
200
|
+
if not ran:
|
|
201
|
+
return []
|
|
202
|
+
position = {node.code: index for index, node in enumerate(nodes)}
|
|
203
|
+
|
|
204
|
+
def finished(code: str) -> tuple[datetime, int]:
|
|
205
|
+
# Two steps can settle in the same millisecond, and the document's order is the only
|
|
206
|
+
# thing left that says which of them the other one waited for.
|
|
207
|
+
return timings[code].finished_at or UNSETTLED, position[code]
|
|
208
|
+
|
|
209
|
+
chain: list[str] = []
|
|
210
|
+
seen: set[str] = set()
|
|
211
|
+
current: str | None = max(ran, key=finished)
|
|
212
|
+
while current is not None and current not in seen:
|
|
213
|
+
chain.append(current)
|
|
214
|
+
seen.add(current)
|
|
215
|
+
upstream = [code for code in ran[current].depends_on if code in ran and code not in seen]
|
|
216
|
+
current = max(upstream, key=finished) if upstream else None
|
|
217
|
+
return list(reversed(chain))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _warnings(
|
|
221
|
+
nodes: Sequence[DagNode], grouped: Mapping[str, list[AttemptOut]], *, at: datetime
|
|
222
|
+
) -> list[ProfileWarning]:
|
|
223
|
+
"""Name the causes the timestamps prove, one sentence each."""
|
|
224
|
+
found: list[ProfileWarning] = []
|
|
225
|
+
for node in nodes:
|
|
226
|
+
attempts = grouped.get(node.code, [])
|
|
227
|
+
for attempt in attempts:
|
|
228
|
+
found.extend(_probe_warnings(node.code, attempt, at=at))
|
|
229
|
+
serialised = _serial_fan_out(node, attempts)
|
|
230
|
+
if serialised is not None:
|
|
231
|
+
found.append(serialised)
|
|
232
|
+
return found
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _probe_warnings(step: str, attempt: AttemptOut, *, at: datetime) -> list[ProfileWarning]:
|
|
236
|
+
"""Say when a probe cadence outlasted the work, and when a deadline outlasted the wait."""
|
|
237
|
+
if attempt.poke_count < MIN_PROBES:
|
|
238
|
+
return []
|
|
239
|
+
timing = attempt_timing(attempt, at=at)
|
|
240
|
+
found: list[ProfileWarning] = []
|
|
241
|
+
# The parked span alone, so a retry's backoff is not read as one of the probe intervals.
|
|
242
|
+
parked_ms = _ms(attempt.started_at, attempt.heartbeat_at)
|
|
243
|
+
cadence = round(parked_ms / attempt.poke_count)
|
|
244
|
+
# Only a probe that was measured proves anything: an attempt the deadline ended while it
|
|
245
|
+
# lay parked has no work to compare its cadence against.
|
|
246
|
+
if timing.running_ms > 0 and parked_ms > IDLE_PROBE_RATIO * timing.running_ms:
|
|
247
|
+
found.append(
|
|
248
|
+
ProfileWarning(
|
|
249
|
+
step=step,
|
|
250
|
+
cause="probe-cadence",
|
|
251
|
+
message=(
|
|
252
|
+
f"{step} spent {_seconds(parked_ms)} parked between {attempt.poke_count} probes "
|
|
253
|
+
f"and {_seconds(timing.running_ms)} probing, which is a probe about every "
|
|
254
|
+
f"{_seconds(cadence)} for work that answered in less than one interval."
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
waited = parked_ms + timing.running_ms
|
|
259
|
+
budget = _ms(attempt.started_at, attempt.deadline_at)
|
|
260
|
+
if budget > SLACK_DEADLINE_RATIO * max(waited, 1):
|
|
261
|
+
found.append(
|
|
262
|
+
ProfileWarning(
|
|
263
|
+
step=step,
|
|
264
|
+
cause="slack-deadline",
|
|
265
|
+
message=(
|
|
266
|
+
f"{step} settled after {_seconds(waited)} of waiting under a deadline of "
|
|
267
|
+
f"{_seconds(budget)}, so its budget is {budget // max(waited, 1)} times the wait it needed."
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return found
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _serial_fan_out(node: DagNode, attempts: Sequence[AttemptOut]) -> ProfileWarning | None:
|
|
275
|
+
"""Say when a fan-out's elements ran one after another rather than beside each other."""
|
|
276
|
+
if not node.fan_out:
|
|
277
|
+
return None
|
|
278
|
+
windows = sorted(
|
|
279
|
+
(row.started_at, row.finished_at)
|
|
280
|
+
for row in attempts
|
|
281
|
+
if row.run_item_id is not None and row.started_at is not None and row.finished_at is not None
|
|
282
|
+
)
|
|
283
|
+
if len(windows) < SERIAL_FAN_OUT_MIN:
|
|
284
|
+
return None
|
|
285
|
+
if any(later < earlier_end for (_, earlier_end), (later, _) in zip(windows, windows[1:], strict=False)):
|
|
286
|
+
return None
|
|
287
|
+
return ProfileWarning(
|
|
288
|
+
step=node.code,
|
|
289
|
+
cause="serial-fan-out",
|
|
290
|
+
message=(
|
|
291
|
+
f"the {len(windows)} elements of {node.code} ran one after another rather than beside each other, "
|
|
292
|
+
f"which is a concurrency limit or a single worker turning a fan-out back into a queue."
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _seconds(value: int) -> str:
|
|
298
|
+
"""Spell a measured span the way a sentence reads it."""
|
|
299
|
+
return f"{value / 1000:.1f}s"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def profile(detail: RunDetail, attempts: Sequence[AttemptOut], *, at: datetime) -> RunProfile:
|
|
303
|
+
"""Break a run into where its wall clock went, along the chain that decided it."""
|
|
304
|
+
grouped = by_step(attempts)
|
|
305
|
+
timings = {
|
|
306
|
+
node.code: step_timing(node, grouped[node.code], at=at) for node in detail.dag.nodes if node.code in grouped
|
|
307
|
+
}
|
|
308
|
+
path = critical_path(detail.dag.nodes, timings)
|
|
309
|
+
on_path = [timings[code] for code in path]
|
|
310
|
+
run = detail.run
|
|
311
|
+
return RunProfile(
|
|
312
|
+
run_id=str(run.id),
|
|
313
|
+
pipeline=run.pipeline,
|
|
314
|
+
status=run.status.value,
|
|
315
|
+
duration_ms=_ms(run.started_at, run.finished_at) if run.started_at and run.finished_at else None,
|
|
316
|
+
critical_path=path,
|
|
317
|
+
queued_ms=sum(one.queued_ms for one in on_path),
|
|
318
|
+
running_ms=sum(one.running_ms for one in on_path),
|
|
319
|
+
waiting_ms=sum(one.waiting_ms for one in on_path),
|
|
320
|
+
steps=on_path,
|
|
321
|
+
warnings=_warnings(detail.dag.nodes, grouped, at=at),
|
|
322
|
+
)
|