interloper-api 0.97.0__tar.gz → 0.99.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.97.0 → interloper_api-0.99.0}/PKG-INFO +1 -1
  2. {interloper_api-0.97.0 → interloper_api-0.99.0}/pyproject.toml +1 -1
  3. {interloper_api-0.97.0 → interloper_api-0.99.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/overview.py +187 -142
  5. {interloper_api-0.97.0 → interloper_api-0.99.0}/README.md +0 -0
  6. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/__init__.py +0 -0
  7. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/app.py +0 -0
  8. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/dependencies/__init__.py +0 -0
  9. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/dependencies/auth.py +0 -0
  10. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/dependencies/rbac.py +0 -0
  11. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/dependencies/state.py +0 -0
  12. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/notifications/__init__.py +0 -0
  13. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/notifications/invitations.py +0 -0
  14. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/__init__.py +0 -0
  15. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/admin.py +0 -0
  16. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/agent.py +0 -0
  17. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/auth.py +0 -0
  18. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/backfills.py +0 -0
  19. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/catalog.py +0 -0
  20. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/components.py +0 -0
  21. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/health.py +0 -0
  22. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/oauth.py +0 -0
  23. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/organisations.py +0 -0
  24. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/runs.py +0 -0
  25. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/tokens.py +0 -0
  26. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/routes/websocket.py +0 -0
  27. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/utils/__init__.py +0 -0
  28. {interloper_api-0.97.0 → interloper_api-0.99.0}/src/interloper_api/utils/time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.97.0
3
+ Version: 0.99.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.97.0"
3
+ version = "0.99.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.97.0"
6
+ version = "0.99.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -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, PartitionBounds
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
- HOOK_EVENTS_LOOKBACK = dt.timedelta(days=30)
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
- error = (connection.state or {}).get("last_renewal_error")
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=str(error).splitlines()[0],
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 event failed.
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
- rows: list[CoverageRow]
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, bounds and evidence.
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
- bounds: Each attempted asset's all-time bounds by id.
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
- rows=rows_by_asset[asset.id],
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, day: dt.date, now: dt.datetime) -> int:
609
- """How many partitions the asset owes on one day of its span.
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 the day from its declared start, when
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
- day: A day of the asset's expected span.
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, before raising to the partitions attempted that day.
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
- first_hour = start.hour if isinstance(start, dt.datetime) and start.date() == day else 0
627
- end_hour = now.hour if day == now.date() else 24
628
- return max(1, end_hour - first_hour)
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
- class CoverageDay(BaseModel):
632
- """One group's asset-partitions on one day: expected, covered, failed."""
688
+ @dataclass
689
+ class DayCounts:
690
+ """Asset-partitions per day over a run of consecutive days, as parallel arrays.
633
691
 
634
- date: dt.date
635
- source_id: UUID
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
- @classmethod
642
- def from_assets(
643
- cls, assets: list[AssetCoverage], since: dt.date, until: dt.date, now: dt.datetime
644
- ) -> list[CoverageDay]:
645
- """Roll every asset's days onto its group, summing per group and day.
646
-
647
- Args:
648
- assets: The partitioned assets with their evidence.
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
- Returns:
654
- One entry per group and day with anything expected, by group id then day.
655
- """
656
- merged: dict[tuple[UUID, dt.date], CoverageDay] = {}
657
- for asset in assets:
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) -> list[CoverageDay]:
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
- monthly or yearly key counts toward every day it spans. A partition
670
- is covered once any execution succeeded, failed when one failed and
671
- none succeeded; one attempted but still in flight, or canceled, is
672
- neither, so it reads as missing, as does a day with nothing attempted.
673
- The failed run kept for a day is the greatest id among its failed
674
- runs, so the pick does not depend on row order.
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
- One entry per day owed, oldest first, each carrying the asset's group id.
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, today)
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: dict[dt.date, set[str]] = defaultdict(set)
694
- covered: dict[dt.date, set[str]] = defaultdict(set)
695
- failed: dict[dt.date, set[str]] = defaultdict(set)
696
- failed_run: dict[dt.date, UUID] = {}
697
- for row in asset.rows:
698
- for day in cls._days_of(row.partition_key, first, last):
699
- attempted[day].add(row.partition_key)
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[day].add(row.partition_key)
747
+ covered[i] += 1
702
748
  elif row.failed:
703
- failed[day].add(row.partition_key)
749
+ failed[i] += 1
704
750
  if row.failed_run_id is not None:
705
- failed_run[day] = max(failed_run.get(day, row.failed_run_id), row.failed_run_id)
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 _days_of(cls, key: str, since: dt.date, until: dt.date) -> list[dt.date]:
721
- """List the days a partition key spans, clipped to a range of days.
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
- other: The other entry.
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 entry, keeping the greater failed run id.
764
+ The summed run, from the earliest start to the latest end.
744
765
  """
745
- failed_runs = [run_id for run_id in (self.failed_run_id, other.failed_run_id) if run_id is not None]
746
- return self.model_copy(
747
- update={
748
- "expected": self.expected + other.expected,
749
- "covered": self.covered + other.covered,
750
- "failed": self.failed + other.failed,
751
- "failed_run_id": max(failed_runs, default=None),
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: a source with its assets, or a standalone asset."""
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 from_component(cls, component: Component) -> CoverageSource:
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, its groups by name, and their days by group (in that
802
- order) then date.
840
+ The window and its groups by name, each with its days summed over its assets.
803
841
  """
804
- days = CoverageDay.from_assets(assets, since, until, now)
805
- groups = {asset.group.id: asset.group for asset in assets}
806
- sources = sorted(
807
- (CoverageSource.from_component(groups[group_id]) for group_id in {day.source_id for day in days}),
808
- key=lambda source: (source.name, str(source.id)),
809
- )
810
- rank = {source.id: i for i, source in enumerate(sources)}
811
- days.sort(key=lambda day: (rank[day.source_id], day.date))
812
- return cls(since=since, until=until, sources=sources, days=days)
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 = {event.component_id for event in hook_events if event.event_type == "hook_failed"}
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.partition_bounds(org_id),
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)