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/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
+ )