Module adcp.reporting.receipts.pg

Durable mixed-batch ordinals on the materializer's account-locked connection.

Classes

class PgReportingReceiptStore (*,
pool: AsyncConnectionPool,
clock: Callable[[], datetime] | None = None,
notifications: bool = False)
Expand source code
class PgReportingReceiptStore(PgReportingMaterializerStore):
    """One composition: old public stores plus optional ingestion and capture.

    No network I/O, session lock, or second pool participates in an ordinal.
    All actual receipt timestamps come from PostgreSQL, including when an old
    public conformance clock was supplied to the inherited constructor.
    """

    @_storage_errors("store.create_schema")
    async def create_schema(self) -> None:
        async with self._connection() as connection, connection.transaction():
            await self._create_schema_on(connection)
            root = files("adcp.reporting.ledger")
            await connection.execute(root.joinpath("reporting_materializer.sql").read_text())
            await connection.execute(root.joinpath("reporting_receipt_ingestion.sql").read_text())

    @_storage_errors("store.receipt_ingestion_ready")
    async def receipt_ingestion_ready(self) -> bool:
        async with self._connection() as connection:
            await validate_receipt_schema(connection, notifications=self._notifications_enabled)
        return True

    async def _delivery_time_on(self, connection: Any, record: ReportingDeliveryRecord) -> datetime:
        if isinstance(record, (ReportingRevisionReceiptRecord, ReportingAdjustmentReceiptRecord)):
            return await _now(connection)
        return await super()._delivery_time_on(connection, record)

    async def _commit_record_on(
        self, connection: Any, record: RecordT, *, notify: bool = True, dirty: bool = True
    ) -> tuple[RecordT, bool]:
        stored, added = await super()._commit_record_on(
            connection, record, notify=notify, dirty=dirty
        )
        if added and isinstance(
            stored, (ReportingRevisionReceiptRecord, ReportingAdjustmentReceiptRecord)
        ):
            await self._capture_receipt_on(connection, stored)
        return stored, added

    async def _receipt_batch_on(
        self, connection: Any, caller: ReportingDeliveryPrincipal, batch: ReceiptBatch
    ) -> tuple[Any, ...]:
        key = caller.account_id, caller.consumer_id, batch.key
        row = await (
            await connection.execute(
                "SELECT canonical_request,request_sha256,expected_count,final_response,final_sha256"
                " FROM reporting_receipt_ingestion_batches"
                " WHERE account_id=%s AND consumer_id=%s AND idempotency_key=%s FOR UPDATE",
                key,
            )
        ).fetchone()
        if row is None:
            await connection.execute(
                "INSERT INTO reporting_receipt_ingestion_batches"
                " (account_id,consumer_id,idempotency_key,canonical_request,"
                " request_sha256,expected_count)"
                " VALUES (%s,%s,%s,%s,%s,%s)",
                (*key, batch.canonical_request.decode(), batch.digest, len(batch.items)),
            )
            return batch.canonical_request.decode(), batch.digest, len(batch.items), None, None
        if row[0] != batch.canonical_request.decode() or row[1] != batch.digest:
            raise ReportingReceiptError("IDEMPOTENCY_CONFLICT")
        if row[2] != len(batch.items):
            raise ReportingReceiptError("RECEIPT_HISTORY_CORRUPT")
        return tuple(row)

    async def _receipt_results_on(
        self, connection: Any, caller: ReportingDeliveryPrincipal, batch: ReceiptBatch
    ) -> list[dict[str, Any]]:
        rows = await (
            await connection.execute(
                "SELECT ordinal,receipt_kind,reporting_receipt_id,result,content_sha256"
                " FROM reporting_receipt_ingestion_results"
                " WHERE account_id=%s AND consumer_id=%s AND idempotency_key=%s ORDER BY ordinal",
                (caller.account_id, caller.consumer_id, batch.key),
            )
        ).fetchall()
        items = batch.items
        if len(rows) > len(items) or any(
            r[0] != i
            or r[1] != items[i][0]
            or r[2] != items[i][1]["reporting_receipt_id"]
            or canonical_json_sha256_v1(r[3]) != r[4]
            for i, r in enumerate(rows)
        ):
            raise ReportingReceiptError("RECEIPT_HISTORY_CORRUPT")
        results = [r[3] for r in rows]
        validate_receipt_results(results, batch)
        return results

    async def _prepare_receipt_on(
        self,
        connection: Any,
        caller: ReportingDeliveryPrincipal,
        kind: ReceiptKind,
        item: dict[str, Any],
    ) -> tuple[ReportingReceiptRecord, dict[str, Any], bool]:
        revision_id = item.get("reporting_revision_id", item.get("adjusts_reporting_revision_id"))
        revision = await (
            await connection.execute(
                # SDK-owned column list; every request value is bound below.
                f"SELECT {_REVISION_COLUMNS} FROM reporting_revisions"  # nosec B608
                " WHERE account_id=%s AND reporting_revision_id=%s",
                (caller.account_id, revision_id),
            )
        ).fetchone()
        obligation_id = item.get("reporting_obligation_id")
        if kind == "adjustment_receipt" and revision is not None:
            obligation_id = _revision_from_row(revision).reporting_obligation_id
        row = await (
            await connection.execute(
                # SDK-owned column list; every request value is bound below.
                f"SELECT {_OBLIGATION_COLUMNS} FROM reporting_obligations"  # nosec B608
                " WHERE account_id=%s AND reporting_obligation_id=%s",
                (caller.account_id, obligation_id),
            )
        ).fetchone()
        obligation = _obligation_from_row(row) if row else None
        candidate = receipt_record(kind, item, caller, obligation)
        records = await self._records(connection, caller)
        revisions = await (
            await connection.execute(
                # SDK-owned column list; every request value is bound below.
                f"SELECT {_REVISION_COLUMNS} FROM reporting_revisions"  # nosec B608
                " WHERE account_id=%s AND reporting_obligation_id=%s",
                (caller.account_id, obligation_id),
            )
        ).fetchall()
        stored, added = prepare_receipt(
            candidate,
            records,
            await self._delivery_context(connection, candidate),
            tuple(_revision_from_row(r) for r in revisions),
            await _now(connection),
        )
        return candidate, success_result(stored, added), added

    async def _insert_receipt_result_on(
        self,
        connection: Any,
        caller: ReportingDeliveryPrincipal,
        batch: ReceiptBatch,
        ordinal: int,
        result: dict[str, Any],
    ) -> None:
        kind, item = batch.items[ordinal]
        await connection.execute(
            "INSERT INTO reporting_receipt_ingestion_results"
            " (account_id,consumer_id,idempotency_key,ordinal,receipt_kind,reporting_receipt_id,"
            " receipt_record_id,result,content_sha256) VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s)",
            (
                caller.account_id,
                caller.consumer_id,
                batch.key,
                ordinal,
                kind,
                item["reporting_receipt_id"],
                item["reporting_receipt_id"] if result["result"] != "failed" else None,
                _json(result),
                canonical_json_sha256_v1(result),
            ),
        )

    async def _save_receipt_response_on(
        self,
        connection: Any,
        caller: ReportingDeliveryPrincipal,
        batch: ReceiptBatch,
        results: list[dict[str, Any]],
    ) -> dict[str, Any]:
        response = inject_context(batch.request, {"status": "completed", "results": results})
        validate_receipt_response(response, batch)
        await connection.execute(
            "UPDATE reporting_receipt_ingestion_batches"
            " SET final_response=%s::jsonb,final_sha256=%s,finalized_at=clock_timestamp()"
            " WHERE account_id=%s AND consumer_id=%s AND idempotency_key=%s",
            (
                _json(response),
                canonical_json_sha256_v1(response),
                caller.account_id,
                caller.consumer_id,
                batch.key,
            ),
        )
        return response

    def _assemble_receipt_response(
        self,
        response: dict[str, Any],
        digest: str,
        results: list[dict[str, Any]],
        batch: ReceiptBatch,
    ) -> dict[str, Any]:
        validate_receipt_response(response, batch)
        if canonical_json_sha256_v1(response) != digest or response["results"] != results:
            raise ReportingReceiptError("RECEIPT_HISTORY_CORRUPT")
        return cast(dict[str, Any], json.loads(_json(response)))

    @_storage_errors("store.ingest_receipt_batch")
    async def ingest_receipt_batch(
        self, request: dict[str, Any], *, caller: ReportingDeliveryPrincipal
    ) -> dict[str, Any]:
        batch = ReceiptBatch.parse(request)
        checked = False
        while True:
            async with self._connection() as connection, connection.transaction():
                # Account advisory lock precedes the batch row; no lock pool or
                # second connection can deadlock a size-one application pool.
                await self._lock_account(connection, caller.account_id)
                if not checked:
                    await validate_receipt_schema(
                        connection, notifications=self._notifications_enabled
                    )
                    checked = True
                row = await self._receipt_batch_on(connection, caller, batch)
                results = await self._receipt_results_on(connection, caller, batch)
                if row[3] is not None:
                    return self._assemble_receipt_response(row[3], row[4], results, batch)
                if len(results) == len(batch.items):
                    response = await self._save_receipt_response_on(
                        connection, caller, batch, results
                    )
                    return self._assemble_receipt_response(
                        response, canonical_json_sha256_v1(response), results, batch
                    )
                kind, item = batch.items[len(results)]
                candidate = None
                try:
                    candidate, result, added = await self._prepare_receipt_on(
                        connection, caller, kind, item
                    )
                except LedgerConflictError as error:
                    result, added = failed_result(item, error), False
                # Only pure semantic preparation is caught. SQL/capture/result
                # failures roll back this ordinal instead of recording a failure
                # beside a partially inserted receipt.
                if added:
                    assert candidate is not None
                    stored, inserted = await self._commit_record_on(connection, candidate)
                    assert inserted
                    result = success_result(stored, True)
                await self._insert_receipt_result_on(
                    connection, caller, batch, len(results), result
                )

    async def _capture_receipt_on(self, connection: Any, receipt: ReportingReceiptRecord) -> None:
        assert receipt.received_at is not None
        caller = receipt.scope.principal
        core = await settle_snapshot_on(
            self, connection, account_id=caller.account_id, as_of=receipt.received_at
        )
        row = await (
            await connection.execute(
                "INSERT INTO reporting_receipt_ingestion_heads"
                " (account_id,consumer_id,max_sequence)"
                " VALUES (%s,%s,1) ON CONFLICT (account_id,consumer_id) DO UPDATE"
                " SET max_sequence=reporting_receipt_ingestion_heads.max_sequence+1"
                " RETURNING max_sequence",
                (caller.account_id, caller.consumer_id),
            )
        ).fetchone()
        account = await (
            await connection.execute(
                "UPDATE reporting_materializer_accounts SET captured_sequence=captured_sequence+1"
                " WHERE account_id=%s RETURNING captured_sequence",
                (caller.account_id,),
            )
        ).fetchone()
        if row is None or account is None:
            raise ReportingReceiptError("RECEIPT_HISTORY_CORRUPT")
        value = ReportingReceiptBoundary(
            caller,
            row[0],
            account[0],
            receipt.reporting_receipt_id,
            receipt.received_at,
            private_snapshot(core, caller),
            await self._records(connection, caller),
        ).to_storage()
        await connection.execute(
            "INSERT INTO reporting_receipt_ingestion_boundaries"
            " (account_id,consumer_id,sequence,account_sequence,reporting_receipt_id,"
            " as_of,input,content_sha256)"
            " VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s)",
            (
                caller.account_id,
                caller.consumer_id,
                row[0],
                account[0],
                receipt.reporting_receipt_id,
                receipt.received_at,
                _json(value),
                canonical_json_sha256_v1(value),
            ),
        )

    @_storage_errors("store.read_receipt_boundaries")
    async def read_receipt_boundaries(
        self, *, caller: ReportingDeliveryPrincipal, after: int = 0, limit: int = 100
    ) -> tuple[ReportingReceiptBoundary, ...]:
        if type(after) is not int or after < 0 or type(limit) is not int or not 1 <= limit <= 100:
            raise ReportingReceiptError("INVALID_REQUEST")
        async with self._connection() as connection, connection.transaction():
            await self._lock_account(connection, caller.account_id)
            await validate_receipt_schema(connection, notifications=self._notifications_enabled)
            rows = await (
                await connection.execute(
                    "SELECT input,content_sha256=reporting_receipt_ingestion_sha256(input)"
                    " FROM reporting_receipt_ingestion_boundaries"
                    " WHERE account_id=%s AND consumer_id=%s AND sequence>%s"
                    " ORDER BY sequence LIMIT %s",
                    (caller.account_id, caller.consumer_id, after, limit),
                )
            ).fetchall()
            if any(not r[1] for r in rows):
                raise ReportingReceiptError("RECEIPT_HISTORY_CORRUPT")
            return tuple(decode_receipt_boundary(r[0]) for r in rows)

One composition: old public stores plus optional ingestion and capture.

No network I/O, session lock, or second pool participates in an ordinal. All actual receipt timestamps come from PostgreSQL, including when an old public conformance clock was supplied to the inherited constructor.

Ancestors

Subclasses

Methods

async def ingest_receipt_batch(self, request: dict[str, Any], *, caller: ReportingDeliveryPrincipal) ‑> dict[str, typing.Any]
Expand source code
@_storage_errors("store.ingest_receipt_batch")
async def ingest_receipt_batch(
    self, request: dict[str, Any], *, caller: ReportingDeliveryPrincipal
) -> dict[str, Any]:
    batch = ReceiptBatch.parse(request)
    checked = False
    while True:
        async with self._connection() as connection, connection.transaction():
            # Account advisory lock precedes the batch row; no lock pool or
            # second connection can deadlock a size-one application pool.
            await self._lock_account(connection, caller.account_id)
            if not checked:
                await validate_receipt_schema(
                    connection, notifications=self._notifications_enabled
                )
                checked = True
            row = await self._receipt_batch_on(connection, caller, batch)
            results = await self._receipt_results_on(connection, caller, batch)
            if row[3] is not None:
                return self._assemble_receipt_response(row[3], row[4], results, batch)
            if len(results) == len(batch.items):
                response = await self._save_receipt_response_on(
                    connection, caller, batch, results
                )
                return self._assemble_receipt_response(
                    response, canonical_json_sha256_v1(response), results, batch
                )
            kind, item = batch.items[len(results)]
            candidate = None
            try:
                candidate, result, added = await self._prepare_receipt_on(
                    connection, caller, kind, item
                )
            except LedgerConflictError as error:
                result, added = failed_result(item, error), False
            # Only pure semantic preparation is caught. SQL/capture/result
            # failures roll back this ordinal instead of recording a failure
            # beside a partially inserted receipt.
            if added:
                assert candidate is not None
                stored, inserted = await self._commit_record_on(connection, candidate)
                assert inserted
                result = success_result(stored, True)
            await self._insert_receipt_result_on(
                connection, caller, batch, len(results), result
            )
async def read_receipt_boundaries(self, *, caller: ReportingDeliveryPrincipal, after: int = 0, limit: int = 100) ‑> tuple[ReportingReceiptBoundary, ...]
Expand source code
@_storage_errors("store.read_receipt_boundaries")
async def read_receipt_boundaries(
    self, *, caller: ReportingDeliveryPrincipal, after: int = 0, limit: int = 100
) -> tuple[ReportingReceiptBoundary, ...]:
    if type(after) is not int or after < 0 or type(limit) is not int or not 1 <= limit <= 100:
        raise ReportingReceiptError("INVALID_REQUEST")
    async with self._connection() as connection, connection.transaction():
        await self._lock_account(connection, caller.account_id)
        await validate_receipt_schema(connection, notifications=self._notifications_enabled)
        rows = await (
            await connection.execute(
                "SELECT input,content_sha256=reporting_receipt_ingestion_sha256(input)"
                " FROM reporting_receipt_ingestion_boundaries"
                " WHERE account_id=%s AND consumer_id=%s AND sequence>%s"
                " ORDER BY sequence LIMIT %s",
                (caller.account_id, caller.consumer_id, after, limit),
            )
        ).fetchall()
        if any(not r[1] for r in rows):
            raise ReportingReceiptError("RECEIPT_HISTORY_CORRUPT")
        return tuple(decode_receipt_boundary(r[0]) for r in rows)
async def receipt_ingestion_ready(self) ‑> bool
Expand source code
@_storage_errors("store.receipt_ingestion_ready")
async def receipt_ingestion_ready(self) -> bool:
    async with self._connection() as connection:
        await validate_receipt_schema(connection, notifications=self._notifications_enabled)
    return True

Inherited members