Choosing a PDF Badge Routing Strategy Under Event Load

Symptom Statement Link to this section

The generator is fine and the badges are correct, but delivery falls apart at scale: a pre-event email blast bounces or lands in spam so attendees arrive with nothing, a presigned link expires between generation and the door, or an on-site spooler that was never load-tested chokes when 3,000 people show up in the first hour. The common thread is not a bug in any one transport — it is that the routing strategy was chosen for a load profile the event does not actually have. A plan tuned for a calm pre-event mailout behaves very differently from one that must print physical badges at a peak-hour check-in. This is the delivery layer owned by PDF routing workflows, and this page frames a single decision: how to pick among email delivery, presigned links, direct-to-print, and a hybrid so the strategy matches when and how badges are consumed. The failure this prevents is picking the wrong strategy for your load profile — a choice that looks fine in a rehearsal of fifty and produces a queue of a thousand on the day.

Decision tree for choosing a badge PDF routing strategy from the event load profile A decision tree starts from the question of when badges are consumed. If badges are consumed both before and during the event, the choice is the hybrid strategy. If badges are consumed pre-event, a second question asks whether attendees self-print from an inbox: yes leads to email via SES, and no — large files or an archival link — leads to presigned S3 links. If badges are consumed on-site, a question asks about venue connectivity: offline or flaky connectivity leads to a hybrid with a local spooler and cloud fallback, while reliable connectivity with a tight latency target leads to direct-to-print spooling. Every leaf names one strategy. When are badges consumed? pre-event both on-site Attendee self-prints from inbox? HYBRID pre-mail + on-site spool Venue connectivity reliable? yes no SES EMAIL attendee inbox PRESIGNED S3 link · large / archival flaky / offline reliable HYBRID local + cloud fallback DIRECT SPOOL printer · lowest latency Every leaf names one strategy; the profile drives the branch, not the reverse. latency · failure blast radius · retry semantics · cost · pre-event vs on-site

Root Cause Analysis Link to this section

Picking the wrong strategy is not one mistake but a mismatch between four load-profile factors and the transport’s real behaviour. Each factor, read wrong, produces a distinct day-of failure.

  • Consumption timing read wrong. A strategy tuned for pre-event mailout (email) is applied to an on-site event where badges must be physical and immediate, so attendees arrive holding a PDF and no printed badge and the desk becomes the printer of last resort.
  • Blast radius underestimated. A single shared transport — one SES sending identity, one spooler, one presigning key — becomes a single point of failure. When it degrades, every undelivered badge fails together instead of a bounded subset, turning a partial incident into a total outage.
  • Retry semantics mismatched. Email retries are provider-driven and slow (minutes to hours); a spooler retry is a fast requeue; a presigned link does not retry at all once it expires. Choosing a transport whose retry rhythm does not match the SLA means failures are discovered too late to recover on the day.
  • Cost and connectivity ignored. A presigned-link or email plan assumes reliable venue connectivity; a flaky hall breaks it. A direct-spool plan assumes local hardware capacity; an under-provisioned printer fleet breaks it. The right strategy is the one whose assumptions match the room.

Strategy Comparison Link to this section

The four strategies are not ranked — each is correct for a different load profile. Read the table by your own constraints: when badges are consumed, how bad a single-transport failure would be, how fast you can retry, what you can spend, and whether the room is online.

Strategy Latency to attendee Failure blast radius Retry semantics Cost Best fit
Email via AWS SES Minutes to hours; async Per-recipient; a bounce isolates to one attendee, but a reputation/identity block hits all Provider-driven with a DLQ; slow (minutes–hours) Low per-message; no on-site hardware Pre-event, attendees self-print
Presigned S3 links Seconds to generate; delivery decoupled Per-link; expiry or a bad key can invalidate a whole batch of links at once Regenerate the link; no in-flight retry Very low storage/egress Pre-event or hybrid; large files, archival, kiosk fetch
Direct-to-print spooler Sub-second to the printer Per-printer/venue; a spooler stall blocks everyone behind it Fast requeue; owned by the spooler Printer fleet + on-site staff On-site, tight latency, physical badge now
Hybrid Pre-event async + on-site sub-second Smallest; a failure in one path fails over to another Layered: email DLQ, link regen, spool requeue Highest — runs two paths Multi-day or mixed events needing both

Email via SES Link to this section

Best when badges are consumed before the attendee arrives and they can print or display their own. Delivery is asynchronous and the blast radius is naturally per-recipient, but it depends on sender reputation and inbox placement, so it is the wrong sole strategy for a same-day, on-site crowd. The provider-side implementation and its DLQ live in automating PDF badge delivery via AWS SES.

Best when the file is large, when you want an archival URL, or when a kiosk or app fetches the badge on demand. Generation is cheap and delivery is decoupled from storage, but a presigned link has a hard expiry and no in-flight retry — if it lapses between generation and use, it must be regenerated. It pairs well as the durable fallback inside a hybrid.

Direct-to-Print Spooler Link to this section

Best on-site, when the SLA is a physical badge in hand within seconds. Latency is the lowest of any strategy and retry is a fast local requeue, but the blast radius is the printer or venue: one stalled spooler blocks everyone behind it, which is why queue behaviour is owned by print queue orchestration.

Hybrid Link to this section

Best for multi-day or mixed events: pre-mail via SES, spool on-site, and archive to S3 so any single path can fail over to another. It has the smallest blast radius and the highest cost, because you operate and monitor two live paths at once.

Minimal Working Implementation Link to this section

A single self-contained selector: a Pydantic v2 RoutingProfile captures the load-profile inputs with validated enums, and choose_strategy maps a profile to exactly one strategy. The assert block proves each branch of the decision tree resolves to the intended choice.

PYTHON
from enum import Enum

from pydantic import BaseModel, ConfigDict, field_validator


class Strategy(str, Enum):
    SES_EMAIL = "ses_email"
    PRESIGNED_S3 = "presigned_s3"
    DIRECT_SPOOL = "direct_spool"
    HYBRID = "hybrid"


_CONSUMED = {"pre_event", "onsite", "both"}
_CONNECTIVITY = {"reliable", "flaky", "offline"}
_TARGET = {"attendee_inbox", "kiosk", "printer"}


class RoutingProfile(BaseModel):
    """The load-profile inputs that drive the routing decision."""
    model_config = ConfigDict(strict=True, extra="forbid")

    consumed: str            # when badges are used: pre_event | onsite | both
    connectivity: str        # venue network: reliable | flaky | offline
    delivery_target: str     # attendee_inbox | kiosk | printer
    badge_volume: int        # total badges to deliver
    latency_sla_s: float     # acceptable seconds from request to delivery

    @field_validator("consumed")
    @classmethod
    def _v_consumed(cls, v: str) -> str:
        if v not in _CONSUMED:
            raise ValueError(f"consumed must be one of {_CONSUMED}")
        return v

    @field_validator("connectivity")
    @classmethod
    def _v_conn(cls, v: str) -> str:
        if v not in _CONNECTIVITY:
            raise ValueError(f"connectivity must be one of {_CONNECTIVITY}")
        return v

    @field_validator("delivery_target")
    @classmethod
    def _v_target(cls, v: str) -> str:
        if v not in _TARGET:
            raise ValueError(f"delivery_target must be one of {_TARGET}")
        return v


def choose_strategy(p: RoutingProfile) -> Strategy:
    """Map a load profile onto exactly one routing strategy."""
    # Mixed consumption always wants layered fallback.
    if p.consumed == "both":
        return Strategy.HYBRID

    if p.consumed == "pre_event":
        # Self-print via inbox unless files are large/archival or volume is huge.
        if p.delivery_target == "attendee_inbox" and p.badge_volume <= 20_000:
            return Strategy.SES_EMAIL
        return Strategy.PRESIGNED_S3

    # on-site
    if p.connectivity == "offline":
        return Strategy.HYBRID          # local spool + cloud archive fallback
    if p.connectivity == "flaky":
        return Strategy.HYBRID
    if p.delivery_target == "printer" and p.latency_sla_s <= 5:
        return Strategy.DIRECT_SPOOL
    return Strategy.DIRECT_SPOOL


# --- Verification: every branch of the tree resolves as intended ------------
if __name__ == "__main__":
    pre_mail = RoutingProfile(consumed="pre_event", connectivity="reliable",
                              delivery_target="attendee_inbox",
                              badge_volume=8_000, latency_sla_s=3600)
    assert choose_strategy(pre_mail) is Strategy.SES_EMAIL

    pre_bulk = RoutingProfile(consumed="pre_event", connectivity="reliable",
                              delivery_target="kiosk",
                              badge_volume=50_000, latency_sla_s=60)
    assert choose_strategy(pre_bulk) is Strategy.PRESIGNED_S3

    onsite_fast = RoutingProfile(consumed="onsite", connectivity="reliable",
                                 delivery_target="printer",
                                 badge_volume=5_000, latency_sla_s=2)
    assert choose_strategy(onsite_fast) is Strategy.DIRECT_SPOOL

    onsite_flaky = RoutingProfile(consumed="onsite", connectivity="flaky",
                                  delivery_target="printer",
                                  badge_volume=5_000, latency_sla_s=2)
    assert choose_strategy(onsite_flaky) is Strategy.HYBRID

    mixed = RoutingProfile(consumed="both", connectivity="reliable",
                           delivery_target="printer",
                           badge_volume=12_000, latency_sla_s=2)
    assert choose_strategy(mixed) is Strategy.HYBRID

    print("OK — all five load profiles resolved to the intended strategy")

The assert block is the decision’s own regression test: it drives one profile through each leaf of the tree and pins the resolved strategy, so a future edit to choose_strategy that misroutes a profile fails loudly instead of shipping the wrong plan to an event.

Memory & Performance Constraints Link to this section

The selector is trivial to run; the constraints that bite are in the transports it chooses and in the batch that feeds them.

Component Constraint Mitigation
SES send rate A per-second send quota throttles a large pre-event blast Meter dispatch against the quota; queue overflow and drain via async batch processing
Presigned link lifetime A short expiry lapses before use; a long one leaks access Set expiry to the consumption window; regenerate on demand rather than pre-minting far ahead
Spooler queue depth A peak-hour surge outruns printer throughput Load-balance jobs across printers and shed to fallback per print queue orchestration
PDF size in transit Large embedded assets inflate email and egress Subset fonts and compress before routing; keep the payload lean upstream
Hybrid state Running two paths doubles the state to reconcile Key every delivery on a single registration_id so the paths dedupe against one ledger

Incident Triage & Rollback Link to this section

Fast path when a chosen strategy is failing under real load — target under 15 minutes to a working fallback.

  1. Name the failing path. Check which transport is erroring: curl -s localhost:9090/metrics | grep -E 'route_(ses|s3|spool)_fail_total'. A single hot counter localizes the blast radius.
  2. Confirm the profile matches reality. Re-run choose_strategy against the actual day-of profile (connectivity, volume, timing). A strategy chosen from a stale profile is the usual root cause.
  3. Fail over, do not fix in place. Switch the live strategy to the fallback the hybrid already defines — for on-site, flip from direct spool to presigned links attendees can fetch at a kiosk; for pre-event, regenerate expired links.
  4. Re-drive undelivered badges. Replay the undelivered set through the new path; because every delivery keys on registration_id, a replay can only re-deliver a missing badge, never duplicate one already handed out.

Rollback. Flip the strategy flag back and redeploy: ROUTING_STRATEGY=hybrid then git revert HEAD~1 --no-edit && docker compose up -d --build. The selector is pure, so reverting reproduces the prior routing decision exactly.

Post-rollback validation. Confirm the selector and the live counters agree before reopening:

BASH
python -m badge.routing  # runs the __main__ asserts; expect the OK line

Frequently Asked Questions Link to this section

Should I just always run the hybrid strategy to be safe? Only if you genuinely consume badges both before and during the event. Hybrid has the smallest blast radius but the highest operational cost — two live paths to run, monitor, and reconcile. For a purely pre-event mailout or a purely on-site print, a single well-chosen strategy is cheaper and has fewer moving parts to fail. Match the strategy to the profile, not to fear.

Presigned links or email for pre-event delivery? Email when attendees self-print from their inbox and the file is small; presigned links when the file is large, when you want an archival URL, or when a kiosk or app fetches on demand. The deciding factors are file size and whether delivery is push (email) or pull (a link the attendee or kiosk opens), not raw cost — both are inexpensive.

How do I switch strategy mid-event without double-printing? Key every delivery on a single registration_id and dedupe against one ledger, exactly as the routing stage requires. Then a mid-event fail-over — say, from direct spool to presigned links — can only deliver a badge that has not been delivered yet; a replay of the undelivered set never re-prints one already in an attendee’s hand.

  • PDF Routing Workflows — the parent stage that owns deterministic, exactly-once delivery; this page is the decision that selects which of its transports to run.
  • Automating PDF Badge Delivery via AWS SES — the email transport in depth, including its dead-letter routing and retry behaviour.
  • Print Queue Orchestration — the on-site queue behind the direct-spool and hybrid strategies, where peak-hour load balancing lives.
  • Async Batch Processing — the durable queue and DLQ that meter dispatch and absorb overflow whichever strategy is chosen.