← back to Unclaimed Property Platform

services/claims/outbox_worker.py

63 lines

"""Outbox worker — drains pending outbox_event rows to the state, completing the pattern.

queue_state_submission writes a 'state_claim_submission_requested' outbox row in the same
transaction as the SUBMITTING transition. This worker delivers it with AT-LEAST-ONCE
semantics + bounded retry:

  - success            -> mark 'sent'
  - transient failure  -> leave 'pending', bump attempts (retried next drain)
  - attempts exhausted -> mark 'failed' (claim NOT lost — stays SUBMITTING for ops review)

At-least-once means a row may be delivered more than once (e.g. adapter succeeded but the
'sent' write didn't land). So "claim already SUBMITTED_TO_STATE" is treated as SUCCESS, not
an error — otherwise a retry after a partial failure would loop forever.
"""
from __future__ import annotations

import json
from uuid import UUID

from services.claims.claim_workflow import ClaimStatus, complete_state_submission


class OutboxWorker:
    def __init__(self, repo, adapter, max_attempts: int = 3) -> None:
        self.repo = repo
        self.adapter = adapter
        self.max_attempts = max_attempts

    def drain_once(self) -> dict:
        sent = failed = retried = 0
        for row in self.repo.fetch_pending_outbox():
            attempts = row["attempts"] + 1
            try:
                self._dispatch(row["event_type"], json.loads(row["payload"]))
                self.repo.mark_outbox(row["outbox_event_id"], "sent", attempts)
                sent += 1
            except Exception as exc:  # noqa: BLE001 - worker isolates each row's failure
                if attempts >= self.max_attempts:
                    self.repo.mark_outbox(row["outbox_event_id"], "failed", attempts, str(exc))
                    failed += 1
                else:
                    self.repo.mark_outbox(row["outbox_event_id"], "pending", attempts, str(exc))
                    retried += 1
        return {"sent": sent, "failed": failed, "retried": retried}

    def _dispatch(self, event_type: str, payload: dict) -> None:
        if event_type == "state_claim_submission_requested":
            self._deliver_submission(payload)
        else:
            raise NotImplementedError(f"no outbox handler for {event_type!r}")

    def _deliver_submission(self, payload: dict) -> None:
        claim_id = UUID(payload["claim_id"])
        key = payload["idempotency_key"]
        try:
            complete_state_submission(self.repo, self.adapter, claim_id, key)
        except ValueError:
            # complete raises if the claim isn't awaiting submission. If it already reached
            # SUBMITTED_TO_STATE, a prior attempt delivered it — idempotent success.
            if self.repo.get_for_update(claim_id).status == ClaimStatus.SUBMITTED_TO_STATE:
                return
            raise