Orchestrating Parallel Filing Batches Across Multiple Jurisdictions
This guide is part of the Multi-Entity Batch Orchestration area within the Deadline Tracking & Routing Engines framework: it drills into the single hardest sub-problem of batch execution — fanning thousands of independent filings out across state registries in parallel without overrunning any one portal or losing the audit thread on a single submission.
Scope of This Page
This page covers the concurrency layer: how to bound parallelism per jurisdiction rather than globally, how to bound retries, how to route an exhausted filing to a recoverable fallout queue, and how to hash every payload for an immutable trail. It deliberately excludes the surrounding machinery documented elsewhere — obligation scoring and admission ordering, the single-intent task model and its state machine, registry reconciliation of confirmation numbers and fees, and the schema validation that admits a payload in the first place. Those belong to the parent area and to Compliance Metadata Schemas; here we assume a stream of already-validated, single-intent tasks and focus only on dispatching them concurrently and safely.
The Constraint That Forces Per-Jurisdiction Parallelism
State portals do not share a concurrency budget, and treating them as if they do is the root cause of most year-boundary batch failures. Delaware’s Division of Corporations serializes sessions per source IP under the franchise-tax regime of Del. Code Ann. tit. 8, § 502 — a burst from one egress address draws a CAPTCHA or a temporary block. California’s BizFile portal, processing Statements of Information under Cal. Corp. Code § 1502, rejects a duplicate submission inside a 72-hour window and is intolerant of non-UTF-8 payload bytes. New York terminates idle Biennial Statement sessions (N.Y. Bus. Corp. Law § 408) after roughly fifteen minutes. A single global thread pool that ignores these differences will simultaneously throttle Delaware, duplicate-reject California, and time out New York. The only correct model is a per-jurisdiction semaphore whose ceiling is tuned to each registry’s observed tolerance, with Delaware and California progressing in parallel but neither exceeding what its own portal enforces.
Prerequisites
- Python 3.10+ — for
X | Yunions, structural typing, and modernasyncio. aiohttp3.9+ — async HTTP with per-host connection pooling (TCPConnector(limit_per_host=...)).- Standard library only beyond that:
asyncio,hashlib,json,logging,time. - Portal credentials / filing accounts for each jurisdiction you dispatch to (Delaware, California, New York, Texas), plus rotating outbound egress IPs for portals that throttle by source address.
- An append-only audit sink (write-once Postgres table or immutable-retention log stream) and a dead-letter queue for fallout replay.
- A stream of pre-validated, single-intent
FilingEntityrecords (see the Multi-Entity Batch Orchestration task model).
Implementation: A Jurisdiction-Scoped Parallel Dispatcher
The module below streams entities through bounded, per-jurisdiction concurrency, retries only transient faults with exponential backoff, classifies portal rejections as terminal, and routes exhausted filings to a fallout queue. Every payload is hashed before transmission so the audit record is tamper-evident. Comments mark the compliance-critical lines.
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from enum import Enum
from typing import AsyncGenerator, Optional
import aiohttp
from aiohttp import ClientSession, TCPConnector
# Structured JSON logging — every line is a parseable audit/observability event.
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("orchestration.parallel_dispatch")
class FilingStatus(Enum):
ACCEPTED = "accepted" # registry returned 200
REJECTED = "rejected" # portal validation failure — terminal, never retried
FALLOUT = "fallout" # transient retries exhausted — routed to manual queue
@dataclass(frozen=True)
class JurisdictionConfig:
code: str # e.g. "US-DE"
max_concurrency: int # per-jurisdiction semaphore ceiling, tuned to portal tolerance
session_timeout_sec: int
base_url: str
@dataclass(frozen=True)
class FilingEntity:
entity_id: str
jurisdiction: str
payload: dict[str, str]
idempotency_key: str # registry-side dedup contract; stable across retries
@dataclass(frozen=True)
class AuditRecord:
entity_id: str
jurisdiction: str
status: FilingStatus
trace_id: str
payload_sha256: str # tamper-evident proof of exactly what was transmitted
timestamp: float
error_detail: Optional[str] = None
class ParallelDispatcher:
def __init__(self, configs: dict[str, JurisdictionConfig], *, max_retries: int = 3) -> None:
self.configs = configs
self.max_retries = max_retries
# One semaphore per jurisdiction: Delaware and California run in parallel,
# but neither ever exceeds the concurrency its own portal will tolerate.
self.semaphores = {
code: asyncio.Semaphore(cfg.max_concurrency) for code, cfg in configs.items()
}
self.session: Optional[ClientSession] = None
self.fallout_queue: asyncio.Queue[AuditRecord] = asyncio.Queue()
async def __aenter__(self) -> "ParallelDispatcher":
# limit_per_host keeps the connection pool jurisdiction-isolated to avoid
# cookie/CSRF-token collisions across portals sharing the same connector.
connector = TCPConnector(limit_per_host=8, ttl_dns_cache=300)
self.session = ClientSession(
connector=connector, timeout=aiohttp.ClientTimeout(total=45)
)
return self
async def __aexit__(self, *exc: object) -> None:
if self.session:
await self.session.close()
@staticmethod
def _payload_hash(payload: dict[str, str]) -> str:
# Canonical JSON (sorted keys) so the hash is reproducible at audit time.
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _headers(self, entity: FilingEntity) -> dict[str, str]:
return {
"Content-Type": "application/json; charset=utf-8",
"X-Idempotency-Key": entity.idempotency_key, # registry rejects dup submissions
"Cache-Control": "no-store", # never trust a cached 304 status
"User-Agent": "ComplianceOrchestrator/2.1",
}
async def _dispatch_one(self, entity: FilingEntity, cfg: JurisdictionConfig) -> AuditRecord:
trace_id = f"{entity.entity_id}-{int(time.time())}"
payload_hash = self._payload_hash(entity.payload)
last_error: Optional[str] = None
for attempt in range(self.max_retries):
try:
# The semaphore is the whole game: it bounds in-flight requests to
# this jurisdiction so a burst never trips its rate limiter.
async with self.semaphores[entity.jurisdiction]:
async with self.session.post( # type: ignore[union-attr]
f"{cfg.base_url}/submit",
headers=self._headers(entity),
json=entity.payload,
timeout=aiohttp.ClientTimeout(total=cfg.session_timeout_sec),
) as resp:
if resp.status == 200:
return AuditRecord(entity.entity_id, entity.jurisdiction,
FilingStatus.ACCEPTED, trace_id,
payload_hash, time.time())
if resp.status == 429: # transient: back off and retry
await asyncio.sleep(2 ** attempt + 0.5)
continue
if resp.status in (400, 409, 422): # statutory/data — terminal
body = await resp.text()
return AuditRecord(entity.entity_id, entity.jurisdiction,
FilingStatus.REJECTED, trace_id,
payload_hash, time.time(),
f"Portal validation failed: {body[:500]}")
raise RuntimeError(f"Unexpected status {resp.status}")
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
last_error = str(exc) # transient transport fault
await asyncio.sleep(2 ** attempt)
# Retries exhausted: never silently drop — preserve for forensic replay.
record = AuditRecord(entity.entity_id, entity.jurisdiction, FilingStatus.FALLOUT,
trace_id, payload_hash, time.time(),
f"Max retries exceeded. Last error: {last_error}")
await self.fallout_queue.put(record)
return record
async def run(self, source: AsyncGenerator[FilingEntity, None]) -> list[AuditRecord]:
tasks: list[asyncio.Task[AuditRecord]] = []
async for entity in source:
cfg = self.configs.get(entity.jurisdiction)
if cfg is None: # unknown jurisdiction is a data defect, not a filing
logger.warning(json.dumps({"event": "unknown_jurisdiction",
"entity_id": entity.entity_id,
"jurisdiction": entity.jurisdiction}))
continue
tasks.append(asyncio.create_task(self._dispatch_one(entity, cfg)))
records = await asyncio.gather(*tasks)
for r in records: # emit one structured audit line per filing decision
logger.info(json.dumps({
"level": "ERROR" if r.status is FilingStatus.REJECTED else "INFO",
"trace_id": r.trace_id, "entity_id": r.entity_id,
"jurisdiction": r.jurisdiction, "status": r.status.value,
"payload_sha256": r.payload_sha256, "ts": r.timestamp,
"error": r.error_detail,
}))
return records
The dispatcher is deliberately a leaf: it makes no priority decisions and performs no reconciliation. It consumes single-intent tasks and returns a typed AuditRecord per filing — accepted, rejected, or fallout — leaving the verdicts to the surrounding orchestration.
Configuration Reference
Concurrency and timeout values are configuration data, never branches in the dispatch loop, because they are dictated by each registry’s enforced behavior rather than by your code.
| Parameter | Suggested value | Legal / operational justification |
|---|---|---|
max_concurrency (US-DE) |
2 | Delaware (§ 502) serializes sessions per source IP; bursts trigger CAPTCHA / IP blocks. |
max_concurrency (US-CA) |
4 | BizFile (§ 1502) tolerates modest parallelism but duplicate-rejects within 72h — keep it low. |
max_concurrency (US-NY) |
3 | DOS (§ 408) drops idle sessions at ~15 min; short-lived concurrent tasks avoid the timeout. |
max_concurrency (US-TX) |
6 | Texas Comptroller tolerates moderate concurrency for the May 15 Public Information Report. |
session_timeout_sec |
30 | Below New York’s ~15-min idle cutoff with wide margin; fails fast on a hung portal. |
max_retries |
3 | Bounds the transient-fault loop so a degraded portal cannot consume the failure budget. |
| Backoff base | 2 ** attempt |
Exponential spacing (1s, 2s, 4s) honoring 429 rate limits without thundering-herd retries. |
limit_per_host |
8 | Caps the connection pool per portal host, preventing cross-jurisdiction cookie/token reuse. |
Failure Modes and Fallback Routing
The dispatcher maps each fault onto the four-tier scheme defined in the parent area’s error categorization & retry logic — transient, statutory, data-validation, and system — and responds to each differently.
429rate limit or CAPTCHA (transient). The portal is signalling pace, not refusal. The loop backs off exponentially and retries withinmax_retries. If a jurisdiction returns sustained429/403, pause its semaphore and rotate egress IP before resuming — do not keep refilling the semaphore, which only deepens the throttle.400/422payload rejection (data-validation). California’s parser rejects trailing whitespace, control characters, or non-UTF-8 bytes. This is terminal: the dispatcher recordsREJECTEDand never retries, because re-sending identical bad bytes cannot succeed. Quarantine the record with its exact payload for correction against the metadata schema, then re-admit upstream.409 Conflictduplicate (statutory/idempotency). The registry has already seen thisidempotency_key. Treat as terminalREJECTED; do not regenerate the key. Out of band, query the portal’s status endpoint by the key — if it confirms prior acceptance, reconcile the local record to accepted; ifUNKNOWN, escalate to manual review.- Timeout or connection error, retries exhausted (system/fallout). A transport fault that survives the backoff loop is routed to the
fallout_queueas aFALLOUTrecord retaining the trace ID, payload hash, and last error. The dead-letter consumer replays it through a human-supervised path without reconstructing batch state. Nothing is ever silently dropped.
Frequently Asked Questions
Why a per-jurisdiction semaphore instead of one global concurrency limit?
Because portals do not share a budget. A single global limit either runs too hot for Delaware (drawing CAPTCHAs and IP blocks) or too cold for Texas (wasting throughput). A per-jurisdiction semaphore lets each registry run at its own safe ceiling simultaneously — Delaware at 2, Texas at 6 — so one strict portal never throttles the whole batch and one tolerant portal is never artificially starved.
Should a `429` and a `422` be retried the same way?
No, and conflating them is a common bug. A 429 is transient — the portal is asking you to slow down — so it backs off and retries. A 422 is a data-validation defect: re-sending the same bytes will fail identically, so it is terminal and quarantined for correction. Retrying terminal rejections burns the failure budget and can trip a circuit breaker on an error that was never going to clear.
What guarantees a parallel batch does not file the same entity twice?
The stable idempotency_key carried in the X-Idempotency-Key header. Even if a retry or a duplicate task reaches the portal, the registry collapses identical keys to a single filing and returns 409 for the repeat — which the dispatcher records as terminal rather than re-submitting. The key is derived upstream from the obligation identity and never regenerated on retry, so concurrency never produces a second filing.
How is a filing that exhausts its retries recovered?
It is enqueued as a FALLOUT AuditRecord on the dead-letter queue with its trace ID, payload hash, and last error preserved. A compliance officer replays the entry through a manual review interface; because the payload and key are intact, the replay re-presents the same idempotency contract and cannot double-file. No batch state needs to be reconstructed to resume the single stuck obligation.