interloper-api 0.96.0__tar.gz → 0.98.0__tar.gz

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 (28) hide show
  1. {interloper_api-0.96.0 → interloper_api-0.98.0}/PKG-INFO +1 -1
  2. {interloper_api-0.96.0 → interloper_api-0.98.0}/pyproject.toml +1 -1
  3. {interloper_api-0.96.0 → interloper_api-0.98.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/app.py +2 -0
  5. interloper_api-0.98.0/src/interloper_api/routes/overview.py +997 -0
  6. interloper_api-0.98.0/src/interloper_api/utils/__init__.py +5 -0
  7. interloper_api-0.98.0/src/interloper_api/utils/time.py +41 -0
  8. {interloper_api-0.96.0 → interloper_api-0.98.0}/README.md +0 -0
  9. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/__init__.py +0 -0
  11. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/auth.py +0 -0
  12. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/rbac.py +0 -0
  13. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/state.py +0 -0
  14. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/notifications/__init__.py +0 -0
  15. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/notifications/invitations.py +0 -0
  16. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/__init__.py +0 -0
  17. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/admin.py +0 -0
  18. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/agent.py +0 -0
  19. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/auth.py +0 -0
  20. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/backfills.py +0 -0
  21. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/catalog.py +0 -0
  22. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/components.py +0 -0
  23. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/health.py +0 -0
  24. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/oauth.py +0 -0
  25. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/organisations.py +0 -0
  26. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/runs.py +0 -0
  27. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/tokens.py +0 -0
  28. {interloper_api-0.96.0 → interloper_api-0.98.0}/src/interloper_api/routes/websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.96.0
3
+ Version: 0.98.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-api"
3
- version = "0.96.0"
3
+ version = "0.98.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.96.0"
6
+ version = "0.98.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -31,6 +31,7 @@ from interloper_api.routes import (
31
31
  health,
32
32
  oauth,
33
33
  organisations,
34
+ overview,
34
35
  runs,
35
36
  tokens,
36
37
  websocket,
@@ -49,6 +50,7 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
49
50
  components,
50
51
  runs,
51
52
  backfills,
53
+ overview,
52
54
  oauth,
53
55
  tokens,
54
56
  websocket,
@@ -0,0 +1,997 @@
1
+ """Overview API: the landing page's health, attention, schedule and inventory in one read.
2
+
3
+ The store answers what the data says; this module answers what it means
4
+ for the reader. Each section of the response is built from store rows by its
5
+ own model's ``from_*`` constructor, so every tile and list on the page shares
6
+ one vocabulary (see ``docs/superpowers/specs/2026-09-30-overview-page-design.md``).
7
+
8
+ Run and event timestamps pass through ``assume_utc`` before any arithmetic:
9
+ Postgres hands ``TIMESTAMPTZ`` columns back aware, but SQLite has no timezone
10
+ type and hands them back naive, and comparing those with an aware ``now``
11
+ would raise.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as dt
17
+ from collections import Counter, defaultdict
18
+ from dataclasses import dataclass
19
+ from typing import Annotated, Any, Literal, NamedTuple
20
+ from uuid import UUID
21
+
22
+ from fastapi import APIRouter, HTTPException, Query
23
+ from interloper.partitioning.time import TimeGranularity, TimePartition, TimePartitionConfig, TimePartitionWindow
24
+ from interloper.utils.time import assume_utc
25
+ from interloper_db import Backfill, Component, ComponentStatus
26
+ from interloper_db.models import Run
27
+ from interloper_db.store.events import CoverageRow, ErrorGroup
28
+ from pydantic import BaseModel
29
+
30
+ from interloper_api.dependencies import OrgIdDep, StoreDep, ViewerDep
31
+ from interloper_api.routes.runs import RunResponse
32
+ from interloper_api.utils import format_duration, job_zone
33
+
34
+ router = APIRouter(prefix="/overview", tags=["overview"])
35
+
36
+ OVERDUE_AFTER = dt.timedelta(minutes=15)
37
+ # Verdicts only: a step-level failure (dest_write_failed and the like) repeats its operation's own verdict.
38
+ FAILURE_EVENT_TYPES = ("operation_failed", "run_failed")
39
+ INVENTORY_KINDS = ("source", "asset", "destination", "connection", "job", "hook")
40
+ HOOK_FAILURE_HORIZON = dt.timedelta(days=30)
41
+ RECENT_LIMIT = 5
42
+ MAX_COVERAGE_SPAN_DAYS = 400
43
+ TERMINAL_STATUSES = frozenset({"success", "failed", "canceled"})
44
+ DRIFT_TITLES = {
45
+ ComponentStatus.MISSING: "{name} is missing from the catalog",
46
+ ComponentStatus.DISABLED: "{name} is disabled in this deployment",
47
+ ComponentStatus.UNREADABLE: "{name} has an unreadable config",
48
+ }
49
+
50
+
51
+ # -- Response models -----------------------------------------------------------
52
+
53
+
54
+ class HourBucket(BaseModel):
55
+ """Attempts that finished inside one hour, by verdict."""
56
+
57
+ hour: dt.datetime
58
+ succeeded: int
59
+ failed: int
60
+
61
+
62
+ class RunsSummary(BaseModel):
63
+ """Attempts finished over the last 24 clock hours, with their hourly profile.
64
+
65
+ The window starts at the top of the hour 23 hours before *now*'s and ends
66
+ at *now*, so the totals always equal the sum of the bars.
67
+ """
68
+
69
+ total: int
70
+ succeeded: int
71
+ failed: int
72
+ hourly: list[HourBucket]
73
+
74
+ @classmethod
75
+ def from_runs(cls, runs: list[Run], now: dt.datetime) -> RunsSummary:
76
+ """Bucket the attempts that finished in the window by the clock hour they completed in.
77
+
78
+ Args:
79
+ runs: Attempts completed around the window, whether or not they
80
+ started; those that did not complete inside it, or ended other
81
+ than in success or failure, are left out.
82
+ now: The window's end, aware UTC.
83
+
84
+ Returns:
85
+ The verdict totals and 24 hourly buckets, oldest first.
86
+ """
87
+ start = now.replace(minute=0, second=0, microsecond=0) - dt.timedelta(hours=23)
88
+ counts: dict[dt.datetime, Counter[str]] = defaultdict(Counter)
89
+ for run in runs:
90
+ if run.completed_at is None or run.status not in ("success", "failed"):
91
+ continue
92
+ completed = assume_utc(run.completed_at).astimezone(dt.timezone.utc)
93
+ if start <= completed <= now:
94
+ counts[completed.replace(minute=0, second=0, microsecond=0)][run.status] += 1
95
+ hourly = [
96
+ HourBucket(hour=hour, succeeded=counts[hour]["success"], failed=counts[hour]["failed"])
97
+ for hour in (start + dt.timedelta(hours=i) for i in range(24))
98
+ ]
99
+ succeeded = sum(bucket.succeeded for bucket in hourly)
100
+ failed = sum(bucket.failed for bucket in hourly)
101
+ return cls(total=succeeded + failed, succeeded=succeeded, failed=failed, hourly=hourly)
102
+
103
+
104
+ class ActivitySummary(BaseModel):
105
+ """What is executing right now."""
106
+
107
+ running: int
108
+ queued: int
109
+ longest_running_seconds: float | None = None
110
+
111
+ @classmethod
112
+ def from_runs(cls, running: list[Run], queued: int, now: dt.datetime) -> ActivitySummary:
113
+ """Summarise the attempts in flight.
114
+
115
+ Args:
116
+ running: The attempts currently running.
117
+ queued: How many attempts wait in the queue.
118
+ now: The reference instant the running time is measured to.
119
+
120
+ Returns:
121
+ The counts, and the longest running time in seconds (``None``
122
+ when nothing that started is running).
123
+ """
124
+ longest = max(
125
+ ((now - assume_utc(run.started_at)).total_seconds() for run in running if run.started_at), default=None
126
+ )
127
+ return cls(running=len(running), queued=queued, longest_running_seconds=longest)
128
+
129
+
130
+ class BackfillsSummary(BaseModel):
131
+ """Backfills still queued or running, their partitions rolled up."""
132
+
133
+ active: int
134
+ partitions_done: int
135
+ partitions_total: int
136
+
137
+ @classmethod
138
+ def from_backfills(cls, backfills: list[Backfill], counts: dict[UUID, dict[str, int]]) -> BackfillsSummary:
139
+ """Roll the active backfills' partitions up into one progress figure.
140
+
141
+ Args:
142
+ backfills: The active backfills.
143
+ counts: Each backfill's runs per status, by backfill id; a
144
+ backfill absent from it has no runs counted as done.
145
+
146
+ Returns:
147
+ The active count, the partitions in a terminal status and the
148
+ partitions in total.
149
+ """
150
+ done = sum(
151
+ n
152
+ for backfill in backfills
153
+ for status, n in counts.get(backfill.id, {}).items()
154
+ if status in TERMINAL_STATUSES
155
+ )
156
+ return cls(
157
+ active=len(backfills),
158
+ partitions_done=done,
159
+ partitions_total=sum(backfill.partitions for backfill in backfills),
160
+ )
161
+
162
+
163
+ class JobsSummary(BaseModel):
164
+ """Enabled jobs, and how many of them last ended in failure."""
165
+
166
+ enabled: int
167
+ failing: int
168
+
169
+ @classmethod
170
+ def from_jobs(cls, enabled_jobs: list[Component], failing_ids: set[UUID]) -> JobsSummary:
171
+ """Count the enabled jobs and those whose latest attempt failed.
172
+
173
+ Args:
174
+ enabled_jobs: The organisation's enabled job rows.
175
+ failing_ids: Ids of the jobs whose latest attempt failed.
176
+
177
+ Returns:
178
+ The two counts.
179
+ """
180
+ return cls(enabled=len(enabled_jobs), failing=sum(1 for job in enabled_jobs if job.id in failing_ids))
181
+
182
+
183
+ class AttentionItem(BaseModel):
184
+ """One thing that needs a person, with what to open to act on it."""
185
+
186
+ kind: Literal["error_group", "connection", "run_stack", "drift", "overdue"]
187
+ severity: Literal["error", "warning"]
188
+ title: str
189
+ target: str | None = None
190
+ since: dt.datetime | None = None
191
+ component_id: UUID | None = None
192
+ component_kind: str | None = None
193
+ run_id: UUID | None = None
194
+
195
+ @classmethod
196
+ def from_error_groups(cls, groups: list[ErrorGroup], names: dict[UUID, str]) -> list[AttentionItem]:
197
+ """Merge error groups by job and the error's first line.
198
+
199
+ Args:
200
+ groups: The store's error groups (one per job, run, component, type and text).
201
+ names: Job names by id; a group whose run targets anything else
202
+ (an ad-hoc asset run, a deleted target) names no job.
203
+
204
+ Returns:
205
+ One error per job and first line, the most runs first, then by title.
206
+ """
207
+ merged: dict[tuple[UUID | None, str], dict[str, Any]] = {}
208
+ for group in groups:
209
+ line = group.error.splitlines()[0].strip() if group.error else ""
210
+ entry = merged.setdefault(
211
+ (group.job_id, line), {"runs": set(), "last_seen": group.last_seen, "sample": group.run_id}
212
+ )
213
+ entry["runs"].add(group.run_id)
214
+ if group.last_seen > entry["last_seen"]:
215
+ entry["last_seen"] = group.last_seen
216
+ entry["sample"] = group.run_id
217
+ pairs = []
218
+ for (job_id, line), entry in merged.items():
219
+ count = len(entry["runs"])
220
+ name = names.get(job_id) if job_id is not None else None
221
+ item = cls(
222
+ kind="error_group",
223
+ severity="error",
224
+ title=f"{count} run{'s' if count != 1 else ''} failed: {line}",
225
+ target=name,
226
+ since=assume_utc(entry["last_seen"]),
227
+ component_id=job_id,
228
+ component_kind="job" if name is not None else None,
229
+ run_id=entry["sample"],
230
+ )
231
+ pairs.append((count, item))
232
+ return [item for _, item in sorted(pairs, key=lambda pair: (-pair[0], pair[1].title))]
233
+
234
+ @classmethod
235
+ def from_connections(cls, connections: list[Component]) -> list[AttentionItem]:
236
+ """Flag the connections whose last renewal failed.
237
+
238
+ Args:
239
+ connections: The organisation's connection rows.
240
+
241
+ Returns:
242
+ One error per connection carrying ``state.last_renewal_error``.
243
+ """
244
+ items = []
245
+ for connection in connections:
246
+ if not (error := connection.state_text("last_renewal_error")):
247
+ continue
248
+ items.append(
249
+ cls(
250
+ kind="connection",
251
+ severity="error",
252
+ title=f"{connection.name or connection.key} connection needs re-authorisation",
253
+ target=error.splitlines()[0],
254
+ since=connection.state_datetime("last_renewed_at"),
255
+ component_id=connection.id,
256
+ component_kind="connection",
257
+ )
258
+ )
259
+ return items
260
+
261
+ @classmethod
262
+ def from_run_stacks(
263
+ cls, failed: list[Run], names: dict[UUID, str], since: dt.datetime, now: dt.datetime
264
+ ) -> list[AttentionItem]:
265
+ """Flag the stacks whose latest attempt is a retry that still failed.
266
+
267
+ Every stack counts, not only each job's newest one, so a partition
268
+ still failing after retries stays listed once a later run starts.
269
+
270
+ Args:
271
+ failed: The latest attempt of each job stack whose latest attempt
272
+ failed, whether or not that attempt ever started.
273
+ names: Job names by id.
274
+ since: Only stacks that finished at or after this instant count.
275
+ now: Only stacks that finished by this instant count.
276
+
277
+ Returns:
278
+ One error per stack.
279
+ """
280
+ items = []
281
+ for run in failed:
282
+ if run.status != "failed" or run.attempt <= 1 or run.completed_at is None:
283
+ continue
284
+ completed = assume_utc(run.completed_at)
285
+ if not since <= completed <= now:
286
+ continue
287
+ name = names.get(run.component_id) if run.component_id else None
288
+ items.append(
289
+ cls(
290
+ kind="run_stack",
291
+ severity="error",
292
+ title=f"Still failing after {run.attempt} attempts",
293
+ target=f"{name} · {run.partition_key}" if run.partition_key else name,
294
+ since=completed,
295
+ component_id=run.component_id,
296
+ component_kind="job",
297
+ run_id=run.id,
298
+ )
299
+ )
300
+ return items
301
+
302
+ @classmethod
303
+ def from_drift(cls, statuses: list[tuple[Component, ComponentStatus]]) -> list[AttentionItem]:
304
+ """Flag the components whose catalog status is not ``ok``.
305
+
306
+ An owned asset whose source is itself drifted is left out: it drifts
307
+ because its source does, and the source's one warning covers it.
308
+
309
+ Args:
310
+ statuses: Every component row with its read status.
311
+
312
+ Returns:
313
+ One warning per drifted component whose parent, if any, is not drifted.
314
+ """
315
+ drifted = {component.id for component, status in statuses if status is not ComponentStatus.OK}
316
+ return [
317
+ cls(
318
+ kind="drift",
319
+ severity="warning",
320
+ title=DRIFT_TITLES[status].format(name=component.name or component.key),
321
+ target=component.key,
322
+ component_id=component.id,
323
+ component_kind=component.kind,
324
+ )
325
+ for component, status in statuses
326
+ if status is not ComponentStatus.OK and component.parent_id not in drifted
327
+ ]
328
+
329
+ @classmethod
330
+ def from_overdue(cls, jobs: list[Component], now: dt.datetime) -> list[AttentionItem]:
331
+ """Flag the enabled jobs whose next slot passed more than :data:`OVERDUE_AFTER` ago.
332
+
333
+ Args:
334
+ jobs: The organisation's job rows.
335
+ now: The reference instant.
336
+
337
+ Returns:
338
+ One warning per overdue job.
339
+ """
340
+ items = []
341
+ for job in jobs:
342
+ due = job.state_datetime("next_run_at")
343
+ if not job.enabled or due is None or now - due <= OVERDUE_AFTER:
344
+ continue
345
+ items.append(
346
+ cls(
347
+ kind="overdue",
348
+ severity="warning",
349
+ title=f"Scheduled run is {format_duration(now - due)} overdue",
350
+ target=job.name or job.key,
351
+ since=due,
352
+ component_id=job.id,
353
+ component_kind="job",
354
+ )
355
+ )
356
+ return items
357
+
358
+
359
+ class UpcomingRun(BaseModel):
360
+ """A job's next firing and the partition window it will cover."""
361
+
362
+ job_id: UUID
363
+ job_name: str
364
+ next_run_at: dt.datetime
365
+ start_key: str | None = None
366
+ end_key: str | None = None
367
+
368
+ @classmethod
369
+ def from_jobs(cls, jobs: list[Component], granularities: dict[UUID, TimeGranularity | None]) -> list[UpcomingRun]:
370
+ """List the enabled jobs by their next firing, each with the window that firing covers.
371
+
372
+ The window is resolved the way the scheduler resolves it: the job's
373
+ ``lookback`` and ``offset`` over its targets' granularity, on the job
374
+ timezone's clock at the firing instant.
375
+
376
+ Args:
377
+ jobs: The organisation's job rows.
378
+ granularities: Each job's target granularity by id; a job absent
379
+ from it, or mapped to ``None``, is unpartitioned.
380
+
381
+ Returns:
382
+ Upcoming firings, soonest first; jobs without a stored next slot are absent.
383
+ """
384
+ upcoming = []
385
+ for job in jobs:
386
+ next_run = job.state_datetime("next_run_at")
387
+ if not job.enabled or next_run is None:
388
+ continue
389
+ window = cls._window(job, granularities.get(job.id), next_run)
390
+ upcoming.append(
391
+ cls(
392
+ job_id=job.id,
393
+ job_name=job.name or job.key,
394
+ next_run_at=next_run,
395
+ start_key=window.granularity.format(window.start) if window else None,
396
+ end_key=window.granularity.format(window.end) if window else None,
397
+ )
398
+ )
399
+ return sorted(upcoming, key=lambda run: run.next_run_at)
400
+
401
+ @classmethod
402
+ def _window(
403
+ cls, job: Component, granularity: TimeGranularity | None, fires_at: dt.datetime
404
+ ) -> TimePartitionWindow | None:
405
+ """Resolve the partition window a job's firing at *fires_at* will cover.
406
+
407
+ Args:
408
+ job: The job row, whose config carries ``lookback``, ``offset`` and ``timezone``.
409
+ granularity: The granularity its targets share, ``None`` when unpartitioned.
410
+ fires_at: The firing instant, aware.
411
+
412
+ Returns:
413
+ The window, or ``None`` for an unpartitioned job, one whose lookback
414
+ is explicitly null, or one whose lookback or offset is out of range.
415
+ """
416
+ config = job.config or {}
417
+ lookback = config.get("lookback", 1)
418
+ if not lookback or granularity is None:
419
+ return None
420
+ try:
421
+ return TimePartitionWindow.lookback(
422
+ fires_at.astimezone(job_zone(config.get("timezone"))),
423
+ lookback=lookback,
424
+ offset=config.get("offset", 1),
425
+ granularity=granularity,
426
+ )
427
+ except ValueError:
428
+ return None
429
+
430
+
431
+ class KindInventory(BaseModel):
432
+ """How many components of one kind sit in each state."""
433
+
434
+ kind: str
435
+ total: int
436
+ healthy: int
437
+ failing: int
438
+ attention: int
439
+ disabled: int
440
+
441
+ @classmethod
442
+ def from_statuses(
443
+ cls,
444
+ statuses: list[tuple[Component, ComponentStatus]],
445
+ failing_ids: set[UUID],
446
+ attention_ids: set[UUID],
447
+ ) -> list[KindInventory]:
448
+ """Count each kind's components by state, first match winning: disabled, failing, attention, healthy.
449
+
450
+ Args:
451
+ statuses: Every component row with its read status.
452
+ failing_ids: Components whose latest execution, attempt or hook firing failed.
453
+ attention_ids: Components needing attention for a reason other than drift.
454
+
455
+ Returns:
456
+ One row per kind in :data:`INVENTORY_KINDS`, in that order.
457
+ """
458
+ counts: dict[str, Counter[str]] = {kind: Counter() for kind in INVENTORY_KINDS}
459
+ for component, status in statuses:
460
+ if component.kind not in counts:
461
+ continue
462
+ if not component.enabled:
463
+ state = "disabled"
464
+ elif component.id in failing_ids:
465
+ state = "failing"
466
+ elif status is not ComponentStatus.OK or component.id in attention_ids:
467
+ state = "attention"
468
+ else:
469
+ state = "healthy"
470
+ counts[component.kind][state] += 1
471
+ return [
472
+ cls(
473
+ kind=kind,
474
+ total=sum(tally.values()),
475
+ healthy=tally["healthy"],
476
+ failing=tally["failing"],
477
+ attention=tally["attention"],
478
+ disabled=tally["disabled"],
479
+ )
480
+ for kind, tally in counts.items()
481
+ ]
482
+
483
+
484
+ class OverviewResponse(BaseModel):
485
+ """Everything the overview page draws, except the coverage calendar."""
486
+
487
+ generated_at: dt.datetime
488
+ runs: RunsSummary
489
+ activity: ActivitySummary
490
+ backfills: BackfillsSummary
491
+ jobs: JobsSummary
492
+ attention: list[AttentionItem]
493
+ upcoming: list[UpcomingRun]
494
+ recent: list[RunResponse]
495
+ components: list[KindInventory]
496
+
497
+
498
+ class PartitionSpan(NamedTuple):
499
+ """The days one partition key covers, of any granularity.
500
+
501
+ Attributes:
502
+ first: The first day of the partition.
503
+ last: The last day of the partition, inclusive.
504
+ """
505
+
506
+ first: dt.date
507
+ last: dt.date
508
+
509
+ @classmethod
510
+ def from_key(cls, key: str) -> PartitionSpan:
511
+ """Parse a time partition key into the days it covers.
512
+
513
+ Args:
514
+ key: A time partition key of any granularity.
515
+
516
+ Returns:
517
+ The days it covers; an hourly key covers its one day.
518
+ """
519
+ start, end = TimePartition.from_key(key).bounds
520
+ # An hourly partition's bounds are datetimes (a datetime is also a date), and it never crosses midnight.
521
+ if isinstance(start, dt.datetime):
522
+ return cls(first=start.date(), last=start.date())
523
+ return cls(first=start, last=end - dt.timedelta(days=1))
524
+
525
+ @classmethod
526
+ def from_spans(cls, spans: list[PartitionSpan]) -> PartitionSpan:
527
+ """Span the days of several partitions, from the earliest first day to the latest last day.
528
+
529
+ Args:
530
+ spans: The partitions' spans, at least one; they may overlap or
531
+ leave gaps, which the result covers.
532
+
533
+ Returns:
534
+ The enclosing span.
535
+ """
536
+ return cls(first=min(span.first for span in spans), last=max(span.last for span in spans))
537
+
538
+
539
+ @dataclass(frozen=True)
540
+ class AssetCoverage:
541
+ """One partitioned asset's evidence, and what the calendar expects of it.
542
+
543
+ Attributes:
544
+ asset: The asset row.
545
+ group: The row the asset counts toward on the calendar: its parent
546
+ source, or the asset itself when standalone.
547
+ partitioning: The asset's partitioning, from its catalog definition.
548
+ scheduled: Whether an enabled job targets the asset or its parent source.
549
+ evidence: Its coverage rows, all-time, each with the days its key covers.
550
+ bounds: The days its attempted partitions span, all-time, or ``None``
551
+ when it was never attempted.
552
+ """
553
+
554
+ asset: Component
555
+ group: Component
556
+ partitioning: TimePartitionConfig
557
+ scheduled: bool
558
+ evidence: list[tuple[PartitionSpan, CoverageRow]]
559
+ bounds: PartitionSpan | None
560
+
561
+ @classmethod
562
+ def from_components(
563
+ cls,
564
+ components: list[Component],
565
+ partitionings: dict[UUID, TimePartitionConfig],
566
+ rows: list[CoverageRow],
567
+ ) -> list[AssetCoverage]:
568
+ """Pair each partitioned asset row with its group, partitioning and evidence.
569
+
570
+ Each distinct partition key is parsed once, however many assets
571
+ executed it.
572
+
573
+ Args:
574
+ components: The organisation's source, asset and job rows, with
575
+ their outgoing relations loaded.
576
+ partitionings: Each partitioned asset's partitioning by id; an
577
+ asset absent from it (unpartitioned, drifted) is left out.
578
+ rows: The store's all-time coverage rows, from runs of any target.
579
+
580
+ Returns:
581
+ One entry per partitioned asset, in the order of *components*.
582
+ """
583
+ by_id = {component.id: component for component in components}
584
+ targeted = {
585
+ relation.dst_id
586
+ for job in components
587
+ if job.kind == "job" and job.enabled
588
+ for relation in job.out_relations
589
+ if relation.name == "targets"
590
+ }
591
+ rows_by_asset: dict[UUID, list[CoverageRow]] = defaultdict(list)
592
+ for row in rows:
593
+ rows_by_asset[row.asset_id].append(row)
594
+ spans: dict[str, PartitionSpan] = {}
595
+ coverages = []
596
+ for asset in components:
597
+ if asset.kind != "asset" or asset.id not in partitionings:
598
+ continue
599
+ group = by_id.get(asset.parent_id) if asset.parent_id else asset
600
+ if group is None:
601
+ continue
602
+ evidence = []
603
+ for row in rows_by_asset[asset.id]:
604
+ span = spans.get(row.partition_key)
605
+ if span is None:
606
+ span = spans[row.partition_key] = PartitionSpan.from_key(row.partition_key)
607
+ evidence.append((span, row))
608
+ bounds = PartitionSpan.from_spans([span for span, _ in evidence]) if evidence else None
609
+ coverages.append(
610
+ cls(
611
+ asset=asset,
612
+ group=group,
613
+ partitioning=partitionings[asset.id],
614
+ scheduled=asset.id in targeted or asset.parent_id in targeted,
615
+ evidence=evidence,
616
+ bounds=bounds,
617
+ )
618
+ )
619
+ return coverages
620
+
621
+ def expected_span(self, now: dt.datetime) -> tuple[dt.date, dt.date] | None:
622
+ """The days the asset owes partitions for, before clipping to a window.
623
+
624
+ The span starts at the declared ``start`` when there is one, else on
625
+ the first day of the earliest attempted partition. It ends on the last
626
+ day of the latest attempted partition; a scheduled asset that is
627
+ enabled, under an enabled group, also owes every period closed before
628
+ *now*: up to yesterday for a daily asset, the end of last month or
629
+ year for a monthly or yearly one, and the hours elapsed today for an
630
+ hourly one.
631
+
632
+ Args:
633
+ now: The reference instant, aware UTC.
634
+
635
+ Returns:
636
+ The first and last day owed, inclusive, or ``None`` when nothing
637
+ is owed: no evidence and no declared start, or a declared start
638
+ with no evidence and no schedule.
639
+ """
640
+ granularity = self.partitioning.granularity
641
+ start = self.partitioning.start
642
+ if start is not None:
643
+ first = start.date() if isinstance(start, dt.datetime) else start
644
+ else:
645
+ first = self.bounds.first if self.bounds else None
646
+ last = self.bounds.last if self.bounds else None
647
+ if self.scheduled and self.asset.enabled and self.group.enabled:
648
+ if granularity is TimeGranularity.HOUR:
649
+ closed = (now - dt.timedelta(hours=1)).date()
650
+ else:
651
+ closed = granularity.truncate(now.date()) - dt.timedelta(days=1)
652
+ last = closed if last is None else max(last, closed)
653
+ if first is None or last is None:
654
+ return None
655
+ return first, last
656
+
657
+ def slots(self, first: dt.date, length: int, now: dt.datetime) -> list[int]:
658
+ """How many partitions the asset owes on each day of a run of days.
659
+
660
+ An hourly asset owes the hours of a day from its declared start, when
661
+ the start falls on that day, to *now*'s hour, when the day is today
662
+ (the hours elapsed since midnight UTC), else to midnight: 24 on a
663
+ full day, at least one. Any other asset owes one slot a day.
664
+
665
+ Args:
666
+ first: The first day of the run, inside the asset's expected span.
667
+ length: How many consecutive days the run holds.
668
+ now: The reference instant, aware UTC.
669
+
670
+ Returns:
671
+ The slots owed per day, index ``i`` being day ``first + i``,
672
+ before raising to the partitions attempted that day.
673
+ """
674
+ if self.partitioning.granularity is not TimeGranularity.HOUR:
675
+ return [1] * length
676
+ start = self.partitioning.start
677
+ partial_days = {now.date(), start.date()} if isinstance(start, dt.datetime) else {now.date()}
678
+ slots = [24] * length
679
+ for day in partial_days:
680
+ index = (day - first).days
681
+ if 0 <= index < length:
682
+ first_hour = start.hour if isinstance(start, dt.datetime) and start.date() == day else 0
683
+ end_hour = now.hour if day == now.date() else 24
684
+ slots[index] = max(1, end_hour - first_hour)
685
+ return slots
686
+
687
+
688
+ @dataclass
689
+ class DayCounts:
690
+ """Asset-partitions per day over a run of consecutive days, as parallel arrays.
691
+
692
+ Index ``i`` of every array, and key ``i`` of :attr:`failed_run_ids`, is
693
+ the day ``start + i``.
694
+
695
+ Attributes:
696
+ start: The first day of the run.
697
+ expected: The asset-partitions owed each day.
698
+ covered: Those covered each day.
699
+ failed: Those failed each day.
700
+ failed_run_ids: The greatest failed run id per day offset, for the
701
+ days with a failed run.
702
+ """
703
+
704
+ start: dt.date
705
+ expected: list[int]
706
+ covered: list[int]
707
+ failed: list[int]
708
+ failed_run_ids: dict[int, UUID]
709
+
710
+ @classmethod
711
+ def from_asset(cls, asset: AssetCoverage, since: dt.date, until: dt.date, now: dt.datetime) -> DayCounts | None:
712
+ """Roll one asset's partitions onto the days of its expected span, clipped to the window and today.
713
+
714
+ Each day owes the asset's :meth:`AssetCoverage.slots`, raised to the
715
+ partitions attempted on it when more. A monthly or yearly key counts
716
+ toward every day it spans. A partition is covered once any execution
717
+ succeeded, failed when one failed and none succeeded; one attempted
718
+ but still in flight, or canceled, is neither, so it reads as missing,
719
+ as does a day with nothing attempted. The failed run kept for a day
720
+ is the greatest id among its failed runs, so the pick does not depend
721
+ on row order.
722
+
723
+ Args:
724
+ asset: The asset with its evidence.
725
+ since: First day of the window.
726
+ until: Last day of the window, inclusive.
727
+ now: The reference instant, aware UTC.
728
+
729
+ Returns:
730
+ The asset's days, or ``None`` when it owes none in the window.
731
+ """
732
+ span = asset.expected_span(now)
733
+ if span is None:
734
+ return None
735
+ first, last = max(span[0], since), min(span[1], until, now.date())
736
+ if first > last:
737
+ return None
738
+ length = (last - first).days + 1
739
+ attempted, covered, failed = [0] * length, [0] * length, [0] * length
740
+ failed_run_ids: dict[int, UUID] = {}
741
+ for key_span, row in asset.evidence:
742
+ if key_span.last < first or key_span.first > last:
743
+ continue
744
+ for i in range((max(key_span.first, first) - first).days, (min(key_span.last, last) - first).days + 1):
745
+ attempted[i] += 1
746
+ if row.succeeded:
747
+ covered[i] += 1
748
+ elif row.failed:
749
+ failed[i] += 1
750
+ if row.failed_run_id is not None:
751
+ failed_run_ids[i] = max(failed_run_ids.get(i, row.failed_run_id), row.failed_run_id)
752
+ expected = [max(owed, count) for owed, count in zip(asset.slots(first, length, now), attempted)]
753
+ return cls(start=first, expected=expected, covered=covered, failed=failed, failed_run_ids=failed_run_ids)
754
+
755
+ @classmethod
756
+ def from_parts(cls, parts: list[DayCounts]) -> DayCounts:
757
+ """Sum runs of days onto one run spanning them all, keeping the greatest failed run id per day.
758
+
759
+ Args:
760
+ parts: The runs to sum, at least one; they may start on different
761
+ days and leave gaps between them, which read as zeros.
762
+
763
+ Returns:
764
+ The summed run, from the earliest start to the latest end.
765
+ """
766
+ start = min(part.start for part in parts)
767
+ length = max((part.start - start).days + len(part.expected) for part in parts)
768
+ merged = cls(start=start, expected=[0] * length, covered=[0] * length, failed=[0] * length, failed_run_ids={})
769
+ for part in parts:
770
+ offset = (part.start - start).days
771
+ for i, (expected, covered, failed) in enumerate(zip(part.expected, part.covered, part.failed)):
772
+ merged.expected[offset + i] += expected
773
+ merged.covered[offset + i] += covered
774
+ merged.failed[offset + i] += failed
775
+ for i, run_id in part.failed_run_ids.items():
776
+ merged.failed_run_ids[offset + i] = max(merged.failed_run_ids.get(offset + i, run_id), run_id)
777
+ return merged
778
+
779
+
780
+ class CoverageSource(BaseModel):
781
+ """A calendar group, a source with its assets or a standalone asset, with its days.
782
+
783
+ The arrays run from :attr:`start` to the group's last expected day in the
784
+ window: index ``i`` is the day ``start + i``, and a day inside that run
785
+ with nothing expected is a zero in every array.
786
+ """
787
+
788
+ id: UUID
789
+ name: str
790
+ kind: Literal["source", "asset"]
791
+ start: dt.date
792
+ expected: list[int]
793
+ covered: list[int]
794
+ failed: list[int]
795
+ failed_run_ids: dict[int, UUID]
796
+
797
+ @classmethod
798
+ def from_days(cls, component: Component, days: DayCounts) -> CoverageSource:
799
+ """Name a group after its row and attach its days.
800
+
801
+ Args:
802
+ component: The source row, or the standalone asset row.
803
+ days: The group's days, summed over its assets.
804
+
805
+ Returns:
806
+ The group, named by the row's name or else its key.
807
+ """
808
+ return cls(
809
+ id=component.id,
810
+ name=component.name or component.key,
811
+ kind="source" if component.kind == "source" else "asset",
812
+ start=days.start,
813
+ expected=days.expected,
814
+ covered=days.covered,
815
+ failed=days.failed,
816
+ failed_run_ids=days.failed_run_ids,
817
+ )
818
+
819
+
820
+ class CoverageResponse(BaseModel):
821
+ """The coverage calendar's data over a window of days."""
822
+
823
+ since: dt.date
824
+ until: dt.date
825
+ sources: list[CoverageSource]
826
+
827
+ @classmethod
828
+ def from_assets(
829
+ cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
830
+ ) -> CoverageResponse:
831
+ """Build the calendar's data, listing only the groups with a day expected in the window.
832
+
833
+ Args:
834
+ assets: The partitioned assets with their evidence.
835
+ since: First day of the window.
836
+ until: Last day of the window, inclusive.
837
+ now: The reference instant, aware UTC.
838
+
839
+ Returns:
840
+ The window and its groups by name, each with its days summed over its assets.
841
+ """
842
+ groups: dict[UUID, Component] = {}
843
+ parts: dict[UUID, list[DayCounts]] = defaultdict(list)
844
+ for asset in assets:
845
+ days = DayCounts.from_asset(asset, since, until, now)
846
+ if days is not None:
847
+ groups[asset.group.id] = asset.group
848
+ parts[asset.group.id].append(days)
849
+ sources = [
850
+ CoverageSource.from_days(groups[group_id], DayCounts.from_parts(group_parts))
851
+ for group_id, group_parts in parts.items()
852
+ ]
853
+ sources.sort(key=lambda source: (source.name, str(source.id)))
854
+ return cls(since=since, until=until, sources=sources)
855
+
856
+
857
+ # -- Endpoints -----------------------------------------------------------------
858
+
859
+
860
+ @router.get("")
861
+ def get_overview(
862
+ user: ViewerDep,
863
+ org_id: OrgIdDep,
864
+ store: StoreDep,
865
+ now: Annotated[
866
+ dt.datetime | None,
867
+ Query(description="Reference instant, a test seam for reproducible reads; defaults to the current time"),
868
+ ] = None,
869
+ ) -> OverviewResponse:
870
+ """Compose the overview page's data for the current organisation.
871
+
872
+ Args:
873
+ user: The authenticated user, required to hold at least the ``viewer`` role.
874
+ org_id: The active organisation's UUID.
875
+ store: The Store instance.
876
+ now: Reference instant. A test seam: tests pin it so every window and
877
+ "overdue" reading is deterministic; clients omit it.
878
+
879
+ Returns:
880
+ The overview.
881
+ """
882
+ now = assume_utc(now or dt.datetime.now(dt.timezone.utc)).astimezone(dt.timezone.utc)
883
+ day_ago = now - dt.timedelta(hours=24)
884
+
885
+ # gather rows
886
+ components = store.components.list_all(org_id)
887
+ keys = {component.id: component.key for component in components}
888
+ statuses = [
889
+ (component, store.components.read(component, parent_key=keys.get(component.parent_id)).status)
890
+ for component in components
891
+ ]
892
+ jobs = [component for component in components if component.kind == "job"]
893
+ enabled_jobs = [job for job in jobs if job.enabled]
894
+ names = {job.id: job.name or job.key for job in jobs}
895
+ running = store.runs.list_all(org_id, status="running", limit=10_000)
896
+ backfills = store.runs.list_backfills(org_id, active_only=True)
897
+ latest_by_job = {run.component_id: run for run in store.runs.latest_by_target(org_id, component_kind="job")}
898
+ failed_stacks = store.runs.list_all(
899
+ org_id, status="failed", component_kind="job", completed_after=day_ago, completed_before=now, limit=10_000
900
+ )
901
+ granularities = store.components.job_partition_granularities([job.id for job in enabled_jobs])
902
+ groups, _ = store.events.error_groups(org_id, event_types=FAILURE_EVENT_TYPES, since=day_ago, until=now)
903
+
904
+ # derive failing ids
905
+ failing_jobs = {job.id for job in enabled_jobs if (run := latest_by_job.get(job.id)) and run.status == "failed"}
906
+ failing_assets = {
907
+ execution.component_id
908
+ for execution in store.events.latest_executions(org_id)
909
+ if execution.status == "failed" and execution.component_id
910
+ }
911
+ failing_sources = {
912
+ component.parent_id
913
+ for component in components
914
+ if component.kind == "asset" and component.id in failing_assets and component.parent_id
915
+ }
916
+ failing_hooks = {
917
+ hook.id
918
+ for hook in components
919
+ if hook.kind == "hook"
920
+ and hook.state_text("last_error")
921
+ and (fired_at := hook.state_datetime("last_fired_at"))
922
+ and fired_at >= now - HOOK_FAILURE_HORIZON
923
+ }
924
+ failing_ids = {i for i in (*failing_jobs, *failing_assets, *failing_sources, *failing_hooks) if i is not None}
925
+
926
+ # build sections
927
+ attention = [
928
+ *AttentionItem.from_error_groups(groups, names),
929
+ *AttentionItem.from_connections([component for component in components if component.kind == "connection"]),
930
+ *AttentionItem.from_run_stacks(failed_stacks, names, day_ago, now),
931
+ *AttentionItem.from_drift(statuses),
932
+ *AttentionItem.from_overdue(jobs, now),
933
+ ]
934
+ attention.sort(key=lambda item: (item.severity != "error", -(item.since.timestamp() if item.since else 0)))
935
+ attention_ids = {item.component_id for item in attention if item.kind == "connection" and item.component_id}
936
+
937
+ return OverviewResponse(
938
+ generated_at=now,
939
+ runs=RunsSummary.from_runs(
940
+ store.runs.list_all(
941
+ org_id, completed_after=day_ago, completed_before=now, all_attempts=True, limit=100_000
942
+ ),
943
+ now,
944
+ ),
945
+ activity=ActivitySummary.from_runs(running, store.runs.count(org_id, status="queued"), now),
946
+ backfills=BackfillsSummary.from_backfills(
947
+ backfills, store.runs.count_backfill_runs([backfill.id for backfill in backfills])
948
+ ),
949
+ jobs=JobsSummary.from_jobs(enabled_jobs, failing_jobs),
950
+ attention=attention,
951
+ upcoming=UpcomingRun.from_jobs(jobs, granularities),
952
+ recent=[
953
+ RunResponse.from_run(run)
954
+ for run in store.runs.list_all(org_id, completed_before=now, sort="-completed_at", limit=RECENT_LIMIT)
955
+ ],
956
+ components=KindInventory.from_statuses(statuses, failing_ids, attention_ids),
957
+ )
958
+
959
+
960
+ @router.get("/coverage")
961
+ def get_coverage(
962
+ user: ViewerDep,
963
+ org_id: OrgIdDep,
964
+ store: StoreDep,
965
+ since: dt.date,
966
+ until: dt.date,
967
+ now: Annotated[
968
+ dt.datetime | None,
969
+ Query(description="Reference instant, a test seam for reproducible reads; defaults to the current time"),
970
+ ] = None,
971
+ ) -> CoverageResponse:
972
+ """Read per source and day coverage over a window of at most :data:`MAX_COVERAGE_SPAN_DAYS` days.
973
+
974
+ Args:
975
+ user: The authenticated user, required to hold at least the ``viewer`` role.
976
+ org_id: The active organisation's UUID.
977
+ store: The Store instance.
978
+ since: First day of the window.
979
+ until: Last day of the window, inclusive.
980
+ now: Reference instant, which decides which periods have closed and
981
+ how much of today is owed. A test seam: tests pin it; clients omit it.
982
+
983
+ Returns:
984
+ The coverage calendar's data.
985
+
986
+ Raises:
987
+ HTTPException: 422 when the window is empty or spans more than :data:`MAX_COVERAGE_SPAN_DAYS` days.
988
+ """
989
+ if until < since or (until - since).days + 1 > MAX_COVERAGE_SPAN_DAYS:
990
+ raise HTTPException(status_code=422, detail=f"The window must span 1 to {MAX_COVERAGE_SPAN_DAYS} days")
991
+ now = assume_utc(now or dt.datetime.now(dt.timezone.utc)).astimezone(dt.timezone.utc)
992
+ assets = AssetCoverage.from_components(
993
+ store.components.list_all(org_id, kinds=["source", "asset", "job"]),
994
+ store.components.asset_partitionings(org_id),
995
+ store.events.coverage_rows(org_id),
996
+ )
997
+ return CoverageResponse.from_assets(assets, since, until, now)
@@ -0,0 +1,5 @@
1
+ """Pure, type-agnostic helpers the API's routes share."""
2
+
3
+ from interloper_api.utils.time import format_duration, job_zone
4
+
5
+ __all__ = ["format_duration", "job_zone"]
@@ -0,0 +1,41 @@
1
+ """Time zones and durations as the API presents them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
7
+
8
+
9
+ def job_zone(name: str | None) -> dt.tzinfo:
10
+ """Resolve a job's timezone name, falling back to UTC like the scheduler does.
11
+
12
+ Args:
13
+ name: The IANA name from the job's config; ``None`` or an empty name
14
+ means UTC.
15
+
16
+ Returns:
17
+ The zone, or UTC when the name is unknown or malformed.
18
+ """
19
+ try:
20
+ return ZoneInfo(name or "UTC")
21
+ except (ZoneInfoNotFoundError, ValueError, TypeError):
22
+ return dt.timezone.utc
23
+
24
+
25
+ def format_duration(delta: dt.timedelta) -> str:
26
+ """Render a duration compactly, keeping its two largest units: ``2d 3h``, ``5h 12m`` or ``12m``.
27
+
28
+ Args:
29
+ delta: The duration; seconds below a whole minute are dropped.
30
+
31
+ Returns:
32
+ The compact label.
33
+ """
34
+ minutes = int(delta.total_seconds() // 60)
35
+ days, minutes = divmod(minutes, 24 * 60)
36
+ hours, minutes = divmod(minutes, 60)
37
+ if days:
38
+ return f"{days}d {hours}h"
39
+ if hours:
40
+ return f"{hours}h {minutes}m"
41
+ return f"{minutes}m"