Implementing Celery for Async Polling in DSR Pipelines

Data Subject Request (DSR) fulfillment operates under statutory clocks — the “without undue delay and in any event within one month” mandate of GDPR Article 12(3) and the 45-day baseline of CCPA §1798.130(a)(2) — while the systems being polled are fragmented SaaS endpoints, legacy relational stores, and event-driven microservices. Anyone building distributed discovery hits the same wall: a single async event loop on one worker (the pattern described in Async Polling & Queue Management) cannot survive a process restart, cannot fan work across a fleet, and cannot enforce per-tenant isolation at the broker level. This page shows how to configure Celery as the distributed execution backbone for that stage of Cross-System Data Discovery & Sync, because Celery’s defaults — round-robin routing, ack-on-receipt, uncapped retries — are actively hostile to a compliance workload. The engineer who reaches this page has an async prototype and needs it to run durably, isolated per tenant, and with an audit trail a supervisory authority will accept.

The task lifecycle below is what every configuration choice on this page protects: a validated DSR task is routed to a queue by sensitivity, polled with a bounded timeout, and either emitted to the audit trail on success, retried on a transient fault, or dead-lettered on a permanent one.

The lifecycle of one Celery DSR polling task, from broker publish to audit ledger, retry, or dead-letter A validated DSR task is published to a RabbitMQ topic exchange under a tenant-scoped routing key, then reserved by a worker in the matching pool and moved to a POLLING state that runs a bounded httpx call. The poll branches three ways. On success the payload is validated, hashed with SHA-256, appended to the WORM audit ledger, and only then acknowledged (late ack). A transient fault (429, 503, connection reset, timeout) takes deterministic exponential backoff capped at retry_backoff_max and is redelivered back to POLLING. A permanent 4xx client error is published to the dsr.dlq.manual queue and the original is acked. Because task_acks_late is enabled, a task left unacked when a worker dies mid-poll is redelivered rather than lost. PUBLISHED → topic exchange routing key {env}.{tenant}.dsr.extract RabbitMQ broker RESERVED by worker matching pool · prefetch = 1 POLLING bounded httpx call · timeout 30s response? status class SUCCESS · 2xx validate (Pydantic v2) SHA-256 → WORM ledger keyed by request.id late ACK task removed from broker TRANSIENT · 5xx/timeout exp backoff, cap = retry_backoff_max retry_jitter = False (deterministic) PERMANENT · 4xx publish to dsr.dlq.manual then ACK original success transient redeliver → POLLING permanent task_acks_late = True — a task left unacked when a worker dies mid-poll is redelivered, never dropped

Prerequisites

  • Python 3.11+ — the code uses zoneinfo, PEP 604 unions, and modern type hints.
  • Celery 5.3+ with a broker. RabbitMQ is assumed because a topic exchange is required for the tenant-scoped routing keys below; Redis works but does not support topic routing natively.
  • Pydantic v2 (pydantic>=2.6) for boundary validation using ConfigDict and field_validator.
  • httpx for bounded upstream polling and psycopg[binary] 3.x (or psycopg2) for the read-replica connection pool.
  • Infrastructure: a broker reachable over TLS, a read-only Postgres replica DSN, a hardware-backed secret manager (Vault or AWS Secrets Manager) for token rotation, and an append-only (WORM) store for the audit ledger. Never hardcode any of these — they arrive through environment variables validated at startup.

This page assumes you have already stood up the single-process async poller and the typed pools from Database Connector Configuration; Celery replaces the ad-hoc event loop, not the connectors underneath it.

Step-by-step implementation

Step 1 — Declare a tenant-aware queue topology

Celery’s default round-robin routing collapses under DSR workloads, where PII classification, data-residency mandates under GDPR Article 44, and retention policy dictate execution priority. Replace implicit routing with explicit queues bound to dedicated worker pools: high-sensitivity extraction routes to dsr.pii.critical, bulk archival synchronization uses dsr.archive.standard, and terminal failures land in dsr.dlq.manual. Prefixing routing keys with environment and tenant ({env}.{tenant}.dsr.extract) makes cross-boundary bleed structurally impossible — a staging payload cannot traverse a production queue because the broker will not match the key.

Celery 5 uses lowercase configuration keys. The uppercase pre-4.0 variants are still read for backward compatibility, but mixing the two styles in one config raises ImproperlyConfigured, so keep everything lowercase.

# celery_config.py  (Celery 5.x, lowercase settings)
from kombu import Exchange, Queue

dsr_exchange = Exchange("dsr_exchange", type="topic")

task_queues: tuple[Queue, ...] = (
    Queue("dsr.pii.critical",     exchange=dsr_exchange, routing_key="prod.eu.dsr.extract"),
    Queue("dsr.archive.standard", exchange=dsr_exchange, routing_key="prod.us.dsr.archive"),
    Queue("dsr.dlq.manual",       exchange=dsr_exchange, routing_key="prod.*.dsr.dlq"),
)

task_default_exchange = "dsr_exchange"
task_default_exchange_type = "topic"
task_default_routing_key = "prod.eu.dsr.extract"

broker_heartbeat = 15          # detect stale workers before partial state corrupts
broker_pool_limit = 10
task_acks_late = True          # redeliver if a worker dies mid-poll
worker_prefetch_multiplier = 1 # long polls must not hoard prefetched tasks
task_reject_on_worker_lost = True

task_acks_late = True with worker_prefetch_multiplier = 1 is the pairing that makes at-least-once delivery safe for long-running polls: a task is acknowledged only after it completes, so a worker crash mid-extraction redelivers the task rather than silently dropping a subject’s request.

Step 2 — Align database timeouts with the task lifecycle

When a worker is killed mid-poll, an uncommitted cursor can lock a compliance table indefinitely. Connection strings arrive only through environment variables with read-only scopes (PGSSLMODE=require, a server-side statement_timeout), and the cursor’s hard limit is synchronized with Celery’s task_time_limit so no query can outlive its task. Initialize the pool once at module level and wrap every extraction in a context manager that guarantees cursor closure regardless of outcome.

import os
from contextlib import contextmanager
from typing import Iterator

import psycopg2
from psycopg2.extensions import cursor as PgCursor
from psycopg2.pool import ThreadedConnectionPool

# Initialize once at import time; shared across all tasks in the worker.
DSR_DB_POOL = ThreadedConnectionPool(
    minconn=2,
    maxconn=10,
    dsn=os.environ["DSR_READ_REPLICA_DSN"],
    connect_timeout=10,
    options="-c statement_timeout=30000",  # 30s server-side hard limit
)


@contextmanager
def get_dsr_cursor() -> Iterator[PgCursor]:
    """Yield a read-scoped cursor with guaranteed commit/rollback/return."""
    conn = DSR_DB_POOL.getconn()
    try:
        yield conn.cursor()
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        DSR_DB_POOL.putconn(conn)

Step 3 — Encode deterministic retry semantics and DLQ fallback

Retry logic in a DSR pipeline must be deterministic, not probabilistic, so the audit trail is reproducible. Pass autoretry_for, retry_backoff, and retry_backoff_max to the @app.task() decorator (not as class attributes on a Task subclass — that is a common Celery 5 mistake). Transient faults (429/503, connection resets, timeouts) get exponential backoff capped at a strict SLA boundary; structural 4xx client errors bypass retries and route straight to the dead-letter queue for manual triage. Every retry preserves self.request.id, maintaining the immutable audit trail that the GDPR Article 17 erasure record and CCPA §1798.130 response record both depend on. This mirrors the transient-versus-permanent failure taxonomy defined for SaaS API Sync Strategies.

import time

import httpx
from celery import Celery, Task

app = Celery("dsr_worker")
app.config_from_object("celery_config")


class ComplianceTask(Task):
    """Base task that emits a structured compliance event on every retry."""

    def on_retry(self, exc: Exception, task_id: str, args, kwargs, einfo) -> None:
        self.log_compliance_event(
            "TASK_RETRY",
            {
                "task_id": task_id,
                "attempt": self.request.retries,
                "error_type": type(exc).__name__,
                "timestamp": time.time(),
                "tenant": kwargs.get("tenant_id"),
            },
        )

    def log_compliance_event(self, event_type: str, payload: dict) -> None:
        """Emit to the centralized SIEM / structured logger (implement per stack)."""
        ...


@app.task(
    base=ComplianceTask,
    bind=True,
    name="dsr.poll_endpoint",
    autoretry_for=(ConnectionError, TimeoutError),
    max_retries=5,
    retry_backoff=True,
    retry_backoff_max=3600,   # cap well inside the statutory SLA window
    retry_jitter=False,       # deterministic backoff for audit reproducibility
)
def poll_endpoint(self, tenant_id: str, endpoint_url: str, auth_token: str) -> dict | None:
    """Poll one discovery endpoint; retry transient faults, DLQ permanent ones."""
    try:
        with httpx.Client(timeout=30) as client:
            resp = client.get(endpoint_url, headers={"Authorization": f"Bearer {auth_token}"})
            resp.raise_for_status()
            return resp.json()
    except httpx.HTTPStatusError as exc:
        if 400 <= exc.response.status_code < 500:
            # Permanent client error: hand to the DLQ, treat as handled (do NOT re-raise).
            route_to_dlq.apply_async(
                kwargs={"tenant_id": tenant_id, "reason": str(exc)},
                queue="dsr.dlq.manual",
            )
            return None
        raise  # 5xx: re-raise so the retry policy reschedules it


@app.task(name="dsr.route_to_dlq")
def route_to_dlq(tenant_id: str, reason: str) -> None:
    """Persist a permanently-failed payload for manual compliance triage."""
    ...

Step 4 — Validate and cryptographically seal every payload

Every payload extracted during async polling must pass schema validation before persistence — a malformed response can corrupt a downstream state machine or raise a false compliance alert. Validate at the task boundary with Pydantic v2, then serialize deterministically, hash with SHA-256, and append the hash beside self.request.id in the WORM audit ledger. Cryptographic hashing lets a downstream consumer verify extraction accuracy without ever re-exposing raw PII, satisfying the audit-and-accountability controls of NIST SP 800-53 Rev. 5 (AU family). This is the same contract enforced site-wide by Schema Validation Rules.

import hashlib
import json
from typing import Literal

from pydantic import BaseModel, ConfigDict, field_validator


class DSRExtractPayload(BaseModel):
    """Validated extraction result sealed into the audit ledger."""

    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")

    subject_id: str
    data_category: Literal["profile", "activity", "financial"]
    records: list[dict]

    @field_validator("subject_id")
    @classmethod
    def opaque_identifier(cls, v: str) -> str:
        """Reject anything but an opaque token so raw PII never enters the ledger key."""
        if not v.replace("-", "").isalnum():
            raise ValueError("subject_id must be an opaque alphanumeric token")
        return v


def validate_and_seal(raw: dict) -> dict:
    """Validate against the DSR schema and attach a SHA-256 integrity hash."""
    payload = DSRExtractPayload(**raw)                       # raises on malformed input
    canonical = payload.model_dump_json()                    # deterministic serialization
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    return {**payload.model_dump(), "integrity_hash": digest}

extra="forbid" enforces data minimization at the boundary per GDPR Article 5(1)©: any undeclared field is rejected rather than silently persisted into a regulated ledger.

Configuration reference

Parameter Type Default Compliance note
task_acks_late bool True Ack after completion so a worker crash redelivers rather than drops a subject’s request.
worker_prefetch_multiplier int 1 Prevents long polls from hoarding prefetched tasks and stalling a partition.
task_reject_on_worker_lost bool True Requeues a task whose worker vanished, closing an at-least-once gap.
broker_heartbeat int (s) 15 Detects stale workers before partial extraction state corrupts a table.
retry_backoff_max int (s) 3600 Cap must stay inside the GDPR Art. 12(3) / CCPA §1798.130 SLA window.
retry_jitter bool False Deterministic backoff keeps retry timing reproducible for auditors.
max_retries int 5 Bounded so a poison task cannot burn the clock indefinitely.
statement_timeout int (ms) 30000 Must be ≤ task_time_limit so no query outlives its task.

The safe ordering to enforce in review is retry_backoff_max ≤ contractual SLA window, and statement_timeouttask_time_limitbroker_heartbeat × 3.

Verification

Confirm correctness before trusting the pipeline with real subject data. Assert that permanent failures never retry, that queues map to worker pools, and that the audit hash is stable and reproducible.

import hashlib
import json

from celery.contrib.testing.worker import start_worker


def test_permanent_4xx_routes_to_dlq(monkeypatch) -> None:
    """A 403 must be handed to the DLQ and return None, never re-raising."""
    calls: list[dict] = []
    monkeypatch.setattr(
        "dsr_worker.route_to_dlq",
        type("Stub", (), {"apply_async": staticmethod(lambda **kw: calls.append(kw))}),
    )
    # ... invoke poll_endpoint against a stub returning 403 ...
    assert calls and calls[0]["queue"] == "dsr.dlq.manual"


def test_integrity_hash_is_deterministic() -> None:
    """Two logically identical payloads must produce the same SHA-256 seal."""
    a = validate_and_seal({"subject_id": "abc-123", "data_category": "profile", "records": []})
    b = validate_and_seal({"subject_id": "abc-123", "data_category": "profile", "records": []})
    assert a["integrity_hash"] == b["integrity_hash"]

Expected log output when a worker starts is one line per declared queue, e.g. celery@w1 ready. queues: dsr.pii.critical, dsr.archive.standard, dsr.dlq.manual. The compliance assertion to hold under load: every task that reaches SUCCESS has exactly one ledger entry keyed by self.request.id, and every PERMANENT failure has exactly one dsr.dlq.manual entry — no task is both absent from the ledger and absent from the DLQ.

Troubleshooting

Tasks silently vanish after a worker restart Root cause: task_acks_late left at its default False, so tasks are acked on receipt and lost when the worker dies mid-poll. Fix: set task_acks_late = True and task_reject_on_worker_lost = True; pair with worker_prefetch_multiplier = 1.

ImproperlyConfigured on startup Root cause: mixing pre-4.0 uppercase keys (CELERY_TASK_QUEUES) with Celery 5 lowercase keys in the same config. Fix: convert every setting to lowercase.

Staging payloads appear on production queues Root cause: a default exchange or a routing key without the {env}.{tenant} prefix, so the topic exchange matches too broadly. Fix: bind every queue through the topic exchange with an environment- and tenant-scoped routing key and remove any direct-exchange fallback.

Retry storm exhausts the upstream API quota Root cause: autoretry_for catching a broad Exception, so 4xx client errors are retried like transient faults. Fix: restrict autoretry_for to (ConnectionError, TimeoutError) and route 4xx explicitly to the DLQ, as in Step 3.

Locked compliance table during network partitions Root cause: a cursor whose lifetime exceeds the task’s, leaving an uncommitted transaction when the worker is killed. Fix: set the server-side statement_timeouttask_time_limit and wrap every query in the get_dsr_cursor() context manager so the connection is always returned.