Resolving PA Pend Race Conditions

When two claims for the same member and drug hit the adjudicator within the same few milliseconds — a pharmacy resubmit racing the original, or a point-of-sale claim racing a prescriber-portal PA request — both can observe “no open prior-authorization case” and both can create one. The decision this page settles is exactly how to stop that: how to guarantee that concurrent threads pending the same (member_token, GPI) converge on a single PA case, with no duplicate PENDING rows and no lost update, without serializing the whole adjudication path behind a lock. The answer is a deterministic case identity plus an atomic upsert guarded by a database unique constraint, and the sections below compare that against the alternatives and implement it. This is the concurrency hardening the Prior Authorization Routing Automation state machine assumes.

The Concurrency Decision

The naive implementation is check-then-create: read whether an open case exists for the member and drug, and if not, insert one. Under concurrency that read-then-write is a classic time-of-check-to-time-of-use gap — both threads read “none,” both write, and the store now holds two cases that will accrue two reviews and can later reach two conflicting decisions. Four mechanisms can close the gap; they differ sharply in latency, blast radius, and how they behave under at-least-once queue redelivery.

Mechanism How it prevents the duplicate Latency cost Failure behavior Fit for PA pend
Pessimistic row/advisory lock Serialize writers on a lock keyed by (member_token, GPI) High — holds a lock across the decision, contends under load Lock timeouts, deadlocks if lock order varies Poor on the hot path; blocks unrelated claims
Optimistic concurrency (version check) Read version, write conditional on unchanged version, retry on conflict Low when uncontended; retry storms when hot Livelock if many writers retry the same key Good for updates to an existing case, not first-create
Idempotency-key dedup (cache) First writer records the key in Redis/DynamoDB; later writers see it Very low Lost dedup if the cache evicts the key mid-race Good as a fast pre-filter, not a system of record
DB unique constraint + atomic upsert INSERT ... ON CONFLICT on a UNIQUE(case_id) — the DB decides the winner Low, single round trip Deterministic; loser gets the existing row, never an error surfaced to the caller Best — the authoritative guarantee

The production answer combines two of these. The unique constraint on a deterministically derived case_id is the system of record guarantee — it cannot be raced because uniqueness is enforced inside the single-writer commit path of the database. An idempotency-key check in the ephemeral claim-state cache sits in front of it as a cheap pre-filter so most duplicates never reach the database at all; the trade-offs of Redis versus DynamoDB for that cache are worked through in Redis vs DynamoDB for Ephemeral Claim State. Optimistic version checks then govern subsequent transitions on the case, which is a different problem from first-create.

Concurrent PA pend race and its resolution by unique constraint Two claim threads for the same member and drug both compute the identical deterministic case_id from member token, GPI and request window. Both attempt an atomic upsert into a cases store protected by a UNIQUE constraint on case_id. The first writer inserts the row and creates the PENDING case; the second writer hits the conflict clause and reads the existing case instead of creating a rival. Both paths converge on exactly one PA case per member, GPI and window. A dashed branch shows that without the atomic upsert, the check-then-create pattern produces two duplicate PENDING cases. Claim T1 Claim T2 case_id = hash(member_token, GPI, request_window) Atomic upsert INSERT ... ON CONFLICT UNIQUE(case_id) First writer: row inserted PENDING case created Second writer: conflict reads existing case Exactly one PA case per (member, GPI, window) Without atomic upsert: check-then-create races → two duplicate PENDINGs

Figure: Both threads derive the identical case_id; the UNIQUE(case_id) constraint lets the first writer create the case and forces the second to attach to it — collapsing the race to one PA case.

Step-by-Step Implementation

The implementation has three moves: derive a collision-proof identity, dedup cheaply in the cache, then commit through an atomic upsert whose conflict clause returns the existing row.

1. Derive a deterministic case_id. The identity must be a pure function of the fields that define “the same PA question,” so both threads compute the same value independently. Use member_token (never 302-C2 Cardholder ID), the GPI (never the raw 407-D7 NDC), and a coarse request_window (for example the ISO date) so rapid resubmits fall in one window.

python
import hashlib

def derive_case_id(member_token: str, gpi: str, request_window: str) -> str:
    # PHI GUARDRAIL: member_token is already opaque; 302-C2 never appears here.
    # Deterministic so concurrent threads independently produce the same id.
    raw = f"{member_token}|{gpi}|{request_window}".encode("utf-8")
    return hashlib.sha256(raw).hexdigest()[:32]

2. Pre-filter with an idempotency key in ephemeral state. A SET key value NX in the claim-state cache lets the first thread claim the case cheaply so most duplicates never touch the database. Treat a cache miss or eviction as “not sure” and fall through to the authoritative upsert — never as proof the case is new.

3. Commit through an atomic upsert. The database, not application code, decides the winner. INSERT ... ON CONFLICT (case_id) DO UPDATE with a RETURNING clause hands back the row whether it was just created or already existed, and a version guard makes any follow-on transition conditional.

python
import json
import logging
from dataclasses import dataclass

logging.basicConfig(format="%(message)s", level=logging.INFO)
logger = logging.getLogger("pa_pend")


@dataclass(frozen=True)
class PendResult:
    case_id: str
    state: str
    created: bool     # True only for the writer that actually inserted


UPSERT_SQL = """
INSERT INTO pa_cases (case_id, member_token, gpi, state, version, request_window)
VALUES (%(case_id)s, %(member_token)s, %(gpi)s, 'PENDING', 1, %(request_window)s)
ON CONFLICT (case_id) DO UPDATE
    SET touched_at = now()          -- no-op update so RETURNING yields the row
RETURNING case_id, state, (xmax = 0) AS created;
"""


def pend_case(cur, member_token: str, gpi: str, request_window: str) -> PendResult:
    """Idempotently open (or attach to) the PA case for this member+GPI+window."""
    case_id = derive_case_id(member_token, gpi, request_window)
    cur.execute(UPSERT_SQL, {
        "case_id": case_id,
        "member_token": member_token,   # opaque, non-PHI
        "gpi": gpi,                     # therapeutic class, not raw 407-D7 NDC
        "request_window": request_window,
    })
    row = cur.fetchone()
    result = PendResult(case_id=row[0], state=row[1], created=bool(row[2]))
    # Audit: tokens + outcome only, never 302-C2 / 310-CA / raw claim bytes.
    logger.info(json.dumps({
        "event": "pa_pend",
        "case_id": result.case_id,
        "member_token": member_token,
        "gpi": gpi,
        "created": result.created,
        "reject_code": "75",            # 511-FB: Prior Authorization Required
    }))
    return result

The xmax = 0 trick reads PostgreSQL’s system column to tell “I inserted this” from “I hit the conflict,” so the winner starts the review workflow exactly once while the loser silently attaches. No lock is held across the decision; the only serialization point is the database’s own uniqueness check, which is single-writer by construction.

Verification and Testing Pattern

The property to pin is: N concurrent pend_case calls for identical inputs create exactly one case, and exactly one caller sees created=True. Drive it with a thread pool against a real transactional store (a unique constraint is the thing under test, so an in-memory dict would prove nothing).

python
import pytest
from concurrent.futures import ThreadPoolExecutor

def test_concurrent_pend_creates_one_case(db_pool):
    member_token, gpi, window = "MTOK_9f2a", "27100010100320", "2026-07-17"

    def worker():
        with db_pool.connection() as conn, conn.cursor() as cur:
            r = pend_case(cur, member_token, gpi, window)
            conn.commit()
            return r

    with ThreadPoolExecutor(max_workers=16) as pool:
        results = [f.result() for f in [pool.submit(worker) for _ in range(16)]]

    case_ids = {r.case_id for r in results}
    assert len(case_ids) == 1                      # one identity, no rivals
    assert sum(1 for r in results if r.created) == 1  # exactly one true creator
    assert all(r.state == "PENDING" for r in results)


def test_case_id_is_deterministic():
    a = derive_case_id("MTOK_9f2a", "27100010100320", "2026-07-17")
    b = derive_case_id("MTOK_9f2a", "27100010100320", "2026-07-17")
    assert a == b                                  # independent threads agree

The first assertion is the load-bearing one: if the unique constraint or the identity derivation is wrong, more than one distinct case_id appears or more than one caller claims created, and the test fails deterministically under the thread pool.

Gotchas and PHI Guardrails

  • Windowing granularity. Too coarse a request_window (a whole month) merges legitimately distinct PA requests; too fine (a timestamp) defeats dedup because two racing claims land in different windows. A calendar date is the usual sweet spot; tune it to the plan’s resubmission behavior.
  • Cache eviction is not a decision. The idempotency-key pre-filter is an optimization only. If the cache evicts the key mid-race, the database unique constraint must still hold — never treat a cache miss as authority to create.
  • Lost updates on follow-on transitions. First-create is solved by uniqueness; a later PENDING → APPROVED still needs an optimistic version guard so a stale writer cannot clobber a newer decision. Retry on version conflict, do not lock.
  • Deadlock from pessimistic locking. If you fall back to row locks, always acquire in a fixed key order; inconsistent lock ordering across (member, GPI) pairs deadlocks under load. The atomic-upsert path avoids this entirely.
  • Fail closed on store outage. If the upsert cannot commit, the claim re-asserts reject 75 and degrades through Fallback Routing Logic Design; never default to creating an unpersisted “approved” case in memory.
  • PHI in logs. Log case_id, member_token, gpi, and the created flag only. The raw 302-C2 Cardholder ID and 310-CA Patient Name were stripped upstream; a debug line echoing the claim to diagnose a race is a reportable breach.

← Back to Prior Authorization Routing Automation