|
73 | 73 | from __future__ import annotations |
74 | 74 |
|
75 | 75 | import asyncio |
| 76 | +import base64 |
76 | 77 | import hashlib |
77 | 78 | import json |
78 | 79 | from collections.abc import AsyncIterator, Callable, Sequence |
|
93 | 94 | from adcp.reporting.currency import require_frozen_currency |
94 | 95 | from adcp.reporting.evidence import ReportingCanonicalDigest, ReportingControlTotalRecord |
95 | 96 | from adcp.reporting.ledger.health import issue_id_for_occurrence |
| 97 | +from adcp.reporting.ledger.host_reads import ReportingCurrentRevisionsPageV1 |
96 | 98 | from adcp.reporting.ledger.models import ( |
97 | 99 | ConsumerStatusRecord, |
98 | 100 | LedgerRecordKind, |
@@ -1664,6 +1666,117 @@ async def get_revision( |
1664 | 1666 | ).fetchone() |
1665 | 1667 | return _revision_from_row(row) if row else None |
1666 | 1668 |
|
| 1669 | + async def get_current_revision( |
| 1670 | + self, *, account_id: str, reporting_obligation_id: str |
| 1671 | + ) -> ReportingRevisionRecord | None: |
| 1672 | + """The obligation's current revision header: the official revision if one exists, |
| 1673 | + otherwise the snapshot nothing supersedes yet. |
| 1674 | +
|
| 1675 | + One indexed lookup. Rows are read separately through the verified paged reader. |
| 1676 | + """ |
| 1677 | + query = "".join( |
| 1678 | + ( |
| 1679 | + "SELECT ", |
| 1680 | + _REVISION_COLUMNS, |
| 1681 | + " FROM reporting_revisions r WHERE r.account_id = %s", |
| 1682 | + " AND r.reporting_obligation_id = %s AND ", |
| 1683 | + _CURRENT_REVISION_FILTER, |
| 1684 | + _CURRENT_REVISION_ORDER, |
| 1685 | + " LIMIT 1", |
| 1686 | + ) |
| 1687 | + ) |
| 1688 | + async with self._connection() as connection: |
| 1689 | + row = await ( |
| 1690 | + await connection.execute(query, (account_id, reporting_obligation_id)) |
| 1691 | + ).fetchone() |
| 1692 | + return _revision_from_row(row) if row else None |
| 1693 | + |
| 1694 | + async def list_current_revisions( |
| 1695 | + self, |
| 1696 | + *, |
| 1697 | + account_id: str, |
| 1698 | + period_start_from: datetime, |
| 1699 | + period_start_to: datetime, |
| 1700 | + delivery_config_id: str | None = None, |
| 1701 | + limit: int = 100, |
| 1702 | + cursor: str | None = None, |
| 1703 | + ) -> ReportingCurrentRevisionsPageV1: |
| 1704 | + """Current revision headers for an account's obligations whose period starts in |
| 1705 | + ``[period_start_from, period_start_to)``, ordered by period start. |
| 1706 | +
|
| 1707 | + Obligations without a revision yet are omitted. Page with ``cursor``; the |
| 1708 | + cursor format matches the JavaScript SDK's. A change-feed consumer that |
| 1709 | + received ``CURSOR_EXPIRED`` resynchronizes from this read. |
| 1710 | + """ |
| 1711 | + if type(limit) is not int or not 1 <= limit <= 1000: |
| 1712 | + raise ValueError("limit must be between 1 and 1000") |
| 1713 | + after: tuple[str, str] | None = None |
| 1714 | + if cursor is not None: |
| 1715 | + try: |
| 1716 | + padded = cursor + "=" * (-len(cursor) % 4) |
| 1717 | + parsed = json.loads(base64.urlsafe_b64decode(padded.encode("ascii"))) |
| 1718 | + start, identity = parsed["start"], parsed["id"] |
| 1719 | + if not isinstance(start, str) or not isinstance(identity, str): |
| 1720 | + raise ValueError("invalid") |
| 1721 | + # A cursor the JavaScript SDK wrote or a corrupted one must fail as a |
| 1722 | + # cursor error here, not as a driver error from the timestamp cast. |
| 1723 | + moment = datetime.fromisoformat(start.replace("Z", "+00:00")) |
| 1724 | + if moment.tzinfo is None: |
| 1725 | + raise ValueError("invalid") |
| 1726 | + after = (start, identity) |
| 1727 | + except (ValueError, KeyError, TypeError, UnicodeError, AttributeError): |
| 1728 | + raise ValueError("current revisions cursor is invalid") from None |
| 1729 | + query = "".join( |
| 1730 | + ( |
| 1731 | + "SELECT ", |
| 1732 | + _OUTER_REVISION_COLUMNS, |
| 1733 | + ", to_char(o.period_start AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"')", |
| 1734 | + " FROM reporting_obligations o JOIN LATERAL (SELECT ", |
| 1735 | + _REVISION_COLUMNS, |
| 1736 | + " FROM reporting_revisions r WHERE r.account_id = o.account_id", |
| 1737 | + " AND r.reporting_obligation_id = o.reporting_obligation_id AND ", |
| 1738 | + _CURRENT_REVISION_FILTER, |
| 1739 | + _CURRENT_REVISION_ORDER, |
| 1740 | + " LIMIT 1) r ON TRUE", |
| 1741 | + " WHERE o.account_id = %s AND o.period_start >= %s AND o.period_start < %s", |
| 1742 | + " AND (%s::text IS NULL OR o.delivery_config_id = %s)", |
| 1743 | + " AND (%s::timestamptz IS NULL OR (o.period_start, o.reporting_obligation_id)", |
| 1744 | + " > (%s::timestamptz, %s::text))", |
| 1745 | + " ORDER BY o.period_start, o.reporting_obligation_id LIMIT %s", |
| 1746 | + ) |
| 1747 | + ) |
| 1748 | + async with self._connection() as connection: |
| 1749 | + rows = await ( |
| 1750 | + await connection.execute( |
| 1751 | + query, |
| 1752 | + ( |
| 1753 | + account_id, |
| 1754 | + period_start_from, |
| 1755 | + period_start_to, |
| 1756 | + delivery_config_id, |
| 1757 | + delivery_config_id, |
| 1758 | + after[0] if after else None, |
| 1759 | + after[0] if after else None, |
| 1760 | + after[1] if after else None, |
| 1761 | + limit + 1, |
| 1762 | + ), |
| 1763 | + ) |
| 1764 | + ).fetchall() |
| 1765 | + page = rows[:limit] |
| 1766 | + next_cursor = None |
| 1767 | + if len(rows) > limit: |
| 1768 | + last = page[-1] |
| 1769 | + next_cursor = ( |
| 1770 | + base64.urlsafe_b64encode( |
| 1771 | + json.dumps({"start": last[-1], "id": last[2]}, separators=(",", ":")).encode() |
| 1772 | + ) |
| 1773 | + .rstrip(b"=") |
| 1774 | + .decode("ascii") |
| 1775 | + ) |
| 1776 | + return ReportingCurrentRevisionsPageV1( |
| 1777 | + tuple(_revision_from_row(row[:-1]) for row in page), next_cursor |
| 1778 | + ) |
| 1779 | + |
1667 | 1780 | async def read_revision_rows( |
1668 | 1781 | self, |
1669 | 1782 | *, |
@@ -3064,6 +3177,17 @@ def _translate_integrity_error(error: Exception) -> LedgerConflictError: |
3064 | 3177 | " canonical_content_digest, managed_control_totals" |
3065 | 3178 | ) |
3066 | 3179 |
|
| 3180 | +_OUTER_REVISION_COLUMNS = ", ".join(f"r.{name.strip()}" for name in _REVISION_COLUMNS.split(",")) |
| 3181 | +# The current revision of an obligation is the official one if it exists, otherwise the |
| 3182 | +# snapshot nothing supersedes yet. An official revision is terminal, so it is never superseded. |
| 3183 | +_CURRENT_REVISION_FILTER = ( |
| 3184 | + "NOT EXISTS (SELECT 1 FROM reporting_revisions s WHERE s.account_id = r.account_id" |
| 3185 | + " AND s.supersedes_reporting_revision_id = r.reporting_revision_id)" |
| 3186 | +) |
| 3187 | +_CURRENT_REVISION_ORDER = ( |
| 3188 | + " ORDER BY (r.finality = 'official') DESC, r.created_at DESC, r.reporting_revision_id DESC" |
| 3189 | +) |
| 3190 | + |
3067 | 3191 | _ADJUSTMENT_COLUMNS = ( |
3068 | 3192 | "reporting_adjustment_id, account_id, adjusts_reporting_revision_id, reason_code," |
3069 | 3193 | " reason_detail, accounting_period_start, accounting_period_end, control_total_deltas," |
|
0 commit comments