interloper-api 0.97.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.
- {interloper_api-0.97.0 → interloper_api-0.98.0}/PKG-INFO +1 -1
- {interloper_api-0.97.0 → interloper_api-0.98.0}/pyproject.toml +1 -1
- {interloper_api-0.97.0 → interloper_api-0.98.0}/pyproject.toml.orig +1 -1
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/overview.py +187 -142
- {interloper_api-0.97.0 → interloper_api-0.98.0}/README.md +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/app.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/__init__.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/auth.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/rbac.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/dependencies/state.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/notifications/__init__.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/notifications/invitations.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/components.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/health.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/routes/websocket.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/utils/__init__.py +0 -0
- {interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/utils/time.py +0 -0
|
@@ -16,15 +16,15 @@ from __future__ import annotations
|
|
|
16
16
|
import datetime as dt
|
|
17
17
|
from collections import Counter, defaultdict
|
|
18
18
|
from dataclasses import dataclass
|
|
19
|
-
from typing import Annotated, Any, Literal
|
|
19
|
+
from typing import Annotated, Any, Literal, NamedTuple
|
|
20
20
|
from uuid import UUID
|
|
21
21
|
|
|
22
22
|
from fastapi import APIRouter, HTTPException, Query
|
|
23
|
-
from interloper.partitioning.time import TimeGranularity, TimePartitionConfig, TimePartitionWindow
|
|
23
|
+
from interloper.partitioning.time import TimeGranularity, TimePartition, TimePartitionConfig, TimePartitionWindow
|
|
24
24
|
from interloper.utils.time import assume_utc
|
|
25
25
|
from interloper_db import Backfill, Component, ComponentStatus
|
|
26
26
|
from interloper_db.models import Run
|
|
27
|
-
from interloper_db.store.events import CoverageRow, ErrorGroup
|
|
27
|
+
from interloper_db.store.events import CoverageRow, ErrorGroup
|
|
28
28
|
from pydantic import BaseModel
|
|
29
29
|
|
|
30
30
|
from interloper_api.dependencies import OrgIdDep, StoreDep, ViewerDep
|
|
@@ -37,7 +37,7 @@ OVERDUE_AFTER = dt.timedelta(minutes=15)
|
|
|
37
37
|
# Verdicts only: a step-level failure (dest_write_failed and the like) repeats its operation's own verdict.
|
|
38
38
|
FAILURE_EVENT_TYPES = ("operation_failed", "run_failed")
|
|
39
39
|
INVENTORY_KINDS = ("source", "asset", "destination", "connection", "job", "hook")
|
|
40
|
-
|
|
40
|
+
HOOK_FAILURE_HORIZON = dt.timedelta(days=30)
|
|
41
41
|
RECENT_LIMIT = 5
|
|
42
42
|
MAX_COVERAGE_SPAN_DAYS = 400
|
|
43
43
|
TERMINAL_STATUSES = frozenset({"success", "failed", "canceled"})
|
|
@@ -243,15 +243,14 @@ class AttentionItem(BaseModel):
|
|
|
243
243
|
"""
|
|
244
244
|
items = []
|
|
245
245
|
for connection in connections:
|
|
246
|
-
|
|
247
|
-
if not error:
|
|
246
|
+
if not (error := connection.state_text("last_renewal_error")):
|
|
248
247
|
continue
|
|
249
248
|
items.append(
|
|
250
249
|
cls(
|
|
251
250
|
kind="connection",
|
|
252
251
|
severity="error",
|
|
253
252
|
title=f"{connection.name or connection.key} connection needs re-authorisation",
|
|
254
|
-
target=
|
|
253
|
+
target=error.splitlines()[0],
|
|
255
254
|
since=connection.state_datetime("last_renewed_at"),
|
|
256
255
|
component_id=connection.id,
|
|
257
256
|
component_kind="connection",
|
|
@@ -450,7 +449,7 @@ class KindInventory(BaseModel):
|
|
|
450
449
|
|
|
451
450
|
Args:
|
|
452
451
|
statuses: Every component row with its read status.
|
|
453
|
-
failing_ids: Components whose latest execution, attempt or hook
|
|
452
|
+
failing_ids: Components whose latest execution, attempt or hook firing failed.
|
|
454
453
|
attention_ids: Components needing attention for a reason other than drift.
|
|
455
454
|
|
|
456
455
|
Returns:
|
|
@@ -496,6 +495,47 @@ class OverviewResponse(BaseModel):
|
|
|
496
495
|
components: list[KindInventory]
|
|
497
496
|
|
|
498
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
|
+
|
|
499
539
|
@dataclass(frozen=True)
|
|
500
540
|
class AssetCoverage:
|
|
501
541
|
"""One partitioned asset's evidence, and what the calendar expects of it.
|
|
@@ -505,36 +545,37 @@ class AssetCoverage:
|
|
|
505
545
|
group: The row the asset counts toward on the calendar: its parent
|
|
506
546
|
source, or the asset itself when standalone.
|
|
507
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.
|
|
508
550
|
bounds: The days its attempted partitions span, all-time, or ``None``
|
|
509
551
|
when it was never attempted.
|
|
510
|
-
scheduled: Whether an enabled job targets the asset or its parent source.
|
|
511
|
-
rows: Its coverage rows for the window.
|
|
512
552
|
"""
|
|
513
553
|
|
|
514
554
|
asset: Component
|
|
515
555
|
group: Component
|
|
516
556
|
partitioning: TimePartitionConfig
|
|
517
|
-
bounds: PartitionBounds | None
|
|
518
557
|
scheduled: bool
|
|
519
|
-
|
|
558
|
+
evidence: list[tuple[PartitionSpan, CoverageRow]]
|
|
559
|
+
bounds: PartitionSpan | None
|
|
520
560
|
|
|
521
561
|
@classmethod
|
|
522
562
|
def from_components(
|
|
523
563
|
cls,
|
|
524
564
|
components: list[Component],
|
|
525
565
|
partitionings: dict[UUID, TimePartitionConfig],
|
|
526
|
-
bounds: dict[UUID, PartitionBounds],
|
|
527
566
|
rows: list[CoverageRow],
|
|
528
567
|
) -> list[AssetCoverage]:
|
|
529
|
-
"""Pair each partitioned asset row with its group, partitioning
|
|
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.
|
|
530
572
|
|
|
531
573
|
Args:
|
|
532
574
|
components: The organisation's source, asset and job rows, with
|
|
533
575
|
their outgoing relations loaded.
|
|
534
576
|
partitionings: Each partitioned asset's partitioning by id; an
|
|
535
577
|
asset absent from it (unpartitioned, drifted) is left out.
|
|
536
|
-
|
|
537
|
-
rows: The store's coverage rows for the window, from runs of any target.
|
|
578
|
+
rows: The store's all-time coverage rows, from runs of any target.
|
|
538
579
|
|
|
539
580
|
Returns:
|
|
540
581
|
One entry per partitioned asset, in the order of *components*.
|
|
@@ -550,6 +591,7 @@ class AssetCoverage:
|
|
|
550
591
|
rows_by_asset: dict[UUID, list[CoverageRow]] = defaultdict(list)
|
|
551
592
|
for row in rows:
|
|
552
593
|
rows_by_asset[row.asset_id].append(row)
|
|
594
|
+
spans: dict[str, PartitionSpan] = {}
|
|
553
595
|
coverages = []
|
|
554
596
|
for asset in components:
|
|
555
597
|
if asset.kind != "asset" or asset.id not in partitionings:
|
|
@@ -557,14 +599,21 @@ class AssetCoverage:
|
|
|
557
599
|
group = by_id.get(asset.parent_id) if asset.parent_id else asset
|
|
558
600
|
if group is None:
|
|
559
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
|
|
560
609
|
coverages.append(
|
|
561
610
|
cls(
|
|
562
611
|
asset=asset,
|
|
563
612
|
group=group,
|
|
564
613
|
partitioning=partitionings[asset.id],
|
|
565
|
-
bounds=bounds.get(asset.id),
|
|
566
614
|
scheduled=asset.id in targeted or asset.parent_id in targeted,
|
|
567
|
-
|
|
615
|
+
evidence=evidence,
|
|
616
|
+
bounds=bounds,
|
|
568
617
|
)
|
|
569
618
|
)
|
|
570
619
|
return coverages
|
|
@@ -605,73 +654,71 @@ class AssetCoverage:
|
|
|
605
654
|
return None
|
|
606
655
|
return first, last
|
|
607
656
|
|
|
608
|
-
def slots(self,
|
|
609
|
-
"""How many partitions the asset owes on
|
|
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.
|
|
610
659
|
|
|
611
|
-
An hourly asset owes the hours of
|
|
660
|
+
An hourly asset owes the hours of a day from its declared start, when
|
|
612
661
|
the start falls on that day, to *now*'s hour, when the day is today
|
|
613
662
|
(the hours elapsed since midnight UTC), else to midnight: 24 on a
|
|
614
663
|
full day, at least one. Any other asset owes one slot a day.
|
|
615
664
|
|
|
616
665
|
Args:
|
|
617
|
-
|
|
666
|
+
first: The first day of the run, inside the asset's expected span.
|
|
667
|
+
length: How many consecutive days the run holds.
|
|
618
668
|
now: The reference instant, aware UTC.
|
|
619
669
|
|
|
620
670
|
Returns:
|
|
621
|
-
The slots owed,
|
|
671
|
+
The slots owed per day, index ``i`` being day ``first + i``,
|
|
672
|
+
before raising to the partitions attempted that day.
|
|
622
673
|
"""
|
|
623
674
|
if self.partitioning.granularity is not TimeGranularity.HOUR:
|
|
624
|
-
return 1
|
|
675
|
+
return [1] * length
|
|
625
676
|
start = self.partitioning.start
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
629
686
|
|
|
630
687
|
|
|
631
|
-
|
|
632
|
-
|
|
688
|
+
@dataclass
|
|
689
|
+
class DayCounts:
|
|
690
|
+
"""Asset-partitions per day over a run of consecutive days, as parallel arrays.
|
|
633
691
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
expected: int
|
|
637
|
-
covered: int
|
|
638
|
-
failed: int
|
|
639
|
-
failed_run_id: UUID | None = None
|
|
692
|
+
Index ``i`` of every array, and key ``i`` of :attr:`failed_run_ids`, is
|
|
693
|
+
the day ``start + i``.
|
|
640
694
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
since: First day of the window.
|
|
650
|
-
until: Last day of the window, inclusive.
|
|
651
|
-
now: The reference instant, aware UTC.
|
|
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
|
+
"""
|
|
652
703
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
for day in cls.from_asset(asset, since, until, now):
|
|
659
|
-
slot = (day.source_id, day.date)
|
|
660
|
-
merged[slot] = merged[slot]._plus(day) if slot in merged else day
|
|
661
|
-
return [merged[slot] for slot in sorted(merged)]
|
|
704
|
+
start: dt.date
|
|
705
|
+
expected: list[int]
|
|
706
|
+
covered: list[int]
|
|
707
|
+
failed: list[int]
|
|
708
|
+
failed_run_ids: dict[int, UUID]
|
|
662
709
|
|
|
663
710
|
@classmethod
|
|
664
|
-
def from_asset(cls, asset: AssetCoverage, since: dt.date, until: dt.date, now: dt.datetime) ->
|
|
711
|
+
def from_asset(cls, asset: AssetCoverage, since: dt.date, until: dt.date, now: dt.datetime) -> DayCounts | None:
|
|
665
712
|
"""Roll one asset's partitions onto the days of its expected span, clipped to the window and today.
|
|
666
713
|
|
|
667
714
|
Each day owes the asset's :meth:`AssetCoverage.slots`, raised to the
|
|
668
|
-
partitions attempted on it when more. A
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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.
|
|
675
722
|
|
|
676
723
|
Args:
|
|
677
724
|
asset: The asset with its evidence.
|
|
@@ -680,92 +727,80 @@ class CoverageDay(BaseModel):
|
|
|
680
727
|
now: The reference instant, aware UTC.
|
|
681
728
|
|
|
682
729
|
Returns:
|
|
683
|
-
|
|
730
|
+
The asset's days, or ``None`` when it owes none in the window.
|
|
684
731
|
"""
|
|
685
|
-
today = now.date()
|
|
686
732
|
span = asset.expected_span(now)
|
|
687
733
|
if span is None:
|
|
688
|
-
return
|
|
689
|
-
first, last = max(span[0], since), min(span[1], until,
|
|
734
|
+
return None
|
|
735
|
+
first, last = max(span[0], since), min(span[1], until, now.date())
|
|
690
736
|
if first > last:
|
|
691
|
-
return
|
|
692
|
-
|
|
693
|
-
attempted
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
for
|
|
699
|
-
attempted[
|
|
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
|
|
700
746
|
if row.succeeded:
|
|
701
|
-
covered[
|
|
747
|
+
covered[i] += 1
|
|
702
748
|
elif row.failed:
|
|
703
|
-
failed[
|
|
749
|
+
failed[i] += 1
|
|
704
750
|
if row.failed_run_id is not None:
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
return
|
|
708
|
-
cls(
|
|
709
|
-
date=day,
|
|
710
|
-
source_id=asset.group.id,
|
|
711
|
-
expected=max(asset.slots(day, now), len(attempted[day])),
|
|
712
|
-
covered=len(covered[day]),
|
|
713
|
-
failed=len(failed[day]),
|
|
714
|
-
failed_run_id=failed_run.get(day),
|
|
715
|
-
)
|
|
716
|
-
for day in (first + dt.timedelta(days=i) for i in range((last - first).days + 1))
|
|
717
|
-
]
|
|
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)
|
|
718
754
|
|
|
719
755
|
@classmethod
|
|
720
|
-
def
|
|
721
|
-
"""
|
|
722
|
-
|
|
723
|
-
Args:
|
|
724
|
-
key: A partition key of any granularity.
|
|
725
|
-
since: First day of the range.
|
|
726
|
-
until: Last day of the range, inclusive.
|
|
727
|
-
|
|
728
|
-
Returns:
|
|
729
|
-
The spanned days inside the range, oldest first; empty when the
|
|
730
|
-
partition lies outside it.
|
|
731
|
-
"""
|
|
732
|
-
span = PartitionBounds.from_keys([key])
|
|
733
|
-
first, last = max(span.first, since), min(span.last, until)
|
|
734
|
-
return [first + dt.timedelta(days=i) for i in range((last - first).days + 1)]
|
|
735
|
-
|
|
736
|
-
def _plus(self, other: CoverageDay) -> CoverageDay:
|
|
737
|
-
"""Sum this day with another of the same group and date.
|
|
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.
|
|
738
758
|
|
|
739
759
|
Args:
|
|
740
|
-
|
|
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.
|
|
741
762
|
|
|
742
763
|
Returns:
|
|
743
|
-
The summed
|
|
764
|
+
The summed run, from the earliest start to the latest end.
|
|
744
765
|
"""
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
754
778
|
|
|
755
779
|
|
|
756
780
|
class CoverageSource(BaseModel):
|
|
757
|
-
"""A calendar group
|
|
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
|
+
"""
|
|
758
787
|
|
|
759
788
|
id: UUID
|
|
760
789
|
name: str
|
|
761
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]
|
|
762
796
|
|
|
763
797
|
@classmethod
|
|
764
|
-
def
|
|
765
|
-
"""Name a group after its row.
|
|
798
|
+
def from_days(cls, component: Component, days: DayCounts) -> CoverageSource:
|
|
799
|
+
"""Name a group after its row and attach its days.
|
|
766
800
|
|
|
767
801
|
Args:
|
|
768
802
|
component: The source row, or the standalone asset row.
|
|
803
|
+
days: The group's days, summed over its assets.
|
|
769
804
|
|
|
770
805
|
Returns:
|
|
771
806
|
The group, named by the row's name or else its key.
|
|
@@ -774,6 +809,11 @@ class CoverageSource(BaseModel):
|
|
|
774
809
|
id=component.id,
|
|
775
810
|
name=component.name or component.key,
|
|
776
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,
|
|
777
817
|
)
|
|
778
818
|
|
|
779
819
|
|
|
@@ -783,13 +823,12 @@ class CoverageResponse(BaseModel):
|
|
|
783
823
|
since: dt.date
|
|
784
824
|
until: dt.date
|
|
785
825
|
sources: list[CoverageSource]
|
|
786
|
-
days: list[CoverageDay]
|
|
787
826
|
|
|
788
827
|
@classmethod
|
|
789
828
|
def from_assets(
|
|
790
829
|
cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
|
|
791
830
|
) -> CoverageResponse:
|
|
792
|
-
"""Build the calendar's data, listing only the groups with a day in the window.
|
|
831
|
+
"""Build the calendar's data, listing only the groups with a day expected in the window.
|
|
793
832
|
|
|
794
833
|
Args:
|
|
795
834
|
assets: The partitioned assets with their evidence.
|
|
@@ -798,18 +837,21 @@ class CoverageResponse(BaseModel):
|
|
|
798
837
|
now: The reference instant, aware UTC.
|
|
799
838
|
|
|
800
839
|
Returns:
|
|
801
|
-
The window
|
|
802
|
-
order) then date.
|
|
840
|
+
The window and its groups by name, each with its days summed over its assets.
|
|
803
841
|
"""
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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)
|
|
813
855
|
|
|
814
856
|
|
|
815
857
|
# -- Endpoints -----------------------------------------------------------------
|
|
@@ -858,9 +900,6 @@ def get_overview(
|
|
|
858
900
|
)
|
|
859
901
|
granularities = store.components.job_partition_granularities([job.id for job in enabled_jobs])
|
|
860
902
|
groups, _ = store.events.error_groups(org_id, event_types=FAILURE_EVENT_TYPES, since=day_ago, until=now)
|
|
861
|
-
hook_events = store.events.latest_by_component(
|
|
862
|
-
org_id, event_types=("hook_fired", "hook_failed"), since=now - HOOK_EVENTS_LOOKBACK
|
|
863
|
-
)
|
|
864
903
|
|
|
865
904
|
# derive failing ids
|
|
866
905
|
failing_jobs = {job.id for job in enabled_jobs if (run := latest_by_job.get(job.id)) and run.status == "failed"}
|
|
@@ -874,7 +913,14 @@ def get_overview(
|
|
|
874
913
|
for component in components
|
|
875
914
|
if component.kind == "asset" and component.id in failing_assets and component.parent_id
|
|
876
915
|
}
|
|
877
|
-
failing_hooks = {
|
|
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
|
+
}
|
|
878
924
|
failing_ids = {i for i in (*failing_jobs, *failing_assets, *failing_sources, *failing_hooks) if i is not None}
|
|
879
925
|
|
|
880
926
|
# build sections
|
|
@@ -946,7 +992,6 @@ def get_coverage(
|
|
|
946
992
|
assets = AssetCoverage.from_components(
|
|
947
993
|
store.components.list_all(org_id, kinds=["source", "asset", "job"]),
|
|
948
994
|
store.components.asset_partitionings(org_id),
|
|
949
|
-
store.events.
|
|
950
|
-
store.events.coverage_rows(org_id, since, until),
|
|
995
|
+
store.events.coverage_rows(org_id),
|
|
951
996
|
)
|
|
952
997
|
return CoverageResponse.from_assets(assets, since, until, now)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/notifications/__init__.py
RENAMED
|
File without changes
|
{interloper_api-0.97.0 → interloper_api-0.98.0}/src/interloper_api/notifications/invitations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|