Print Queue Orchestration: The Badge-Job Acceptance & Ordering Layer
Print queue orchestration is the component that decides, job by job, which physical printer prints which badge and in what order, and it is one of the four stages that make up the On-Site Check-In & Print Routing section. Its scope is narrow and unforgiving: accept a rendered badge as a print job, give it a stable identity, place it on exactly one printer’s queue at the right priority, and refuse to let any single device’s backlog grow past the point where the scan-to-print budget collapses. Everything the floor promises — sub-second median latency, exactly-once printing, no idle printer next to a buried one — is either kept or lost here. This is the layer that a check-in scan hits after it has been turned into a trusted presence event, and it is the layer that feeds the spooler which drives the thermal head.
The reason to isolate this component is operational: queue behavior is where load actually bites, and it must be reasoned about independently of how scans are captured or how a dead printer is drained. This page defines the acceptance contract precisely — the PrintJob shape, the ordering and backpressure rules, the per-printer depth accounting, and the spooler-health signals the dispatcher reads — plus the structured observability an on-call engineer needs to clear a jammed queue in under fifteen minutes. Capturing and reconciling the scan itself belongs to Check-In Kiosk Sync; re-homing work off a failed device belongs to On-Site Print Failover; reissuing a badge on purpose belongs to Badge Reprint Handling. Orchestration owns only the queue.
Scope Boundary Link to this section
Orchestration stays fast and testable only because its job is bounded. Anything not in the left column is delegated to an adjacent stage and must never leak into the enqueue hot path.
| In-Scope (this component owns) | Out-of-Scope (delegated to adjacent stages) |
|---|---|
Accepting a PrintJob and assigning it a queue position |
Turning a raw scan into a trusted check-in event — owned by Check-In Kiosk Sync |
| Ordering jobs by priority within a per-printer queue | Rendering the badge PDF the job references — owned by PDF routing workflows |
| Enforcing per-printer queue depth and backpressure | Detecting a dead device and draining its queue — owned by On-Site Print Failover |
| Structural dedupe so one scan enqueues exactly once | Deliberately reissuing a badge with a reason code — owned by Badge Reprint Handling |
| Reading spooler health to pick a routing target | Guaranteeing only paid records are printable — owned by async batch processing |
| Emitting correlation IDs and per-queue depth metrics | Focused recovery runbooks — see clearing a stuck queue at peak check-in and load-balancing jobs across printers |
The orchestrator holds no attendee state and renders nothing. It receives an accepted job and either places it on exactly one queue or diverts it — as a suppressed duplicate or a shed-under-backpressure job — with a structured reason, which is what lets a queue decision be replayed and audited after the rush is over.
Data Contract & Schema Link to this section
Every job crossing into a queue must satisfy a rigid contract. The PrintJob model below is enforced with Pydantic v2, coercion disabled, so a malformed job is rejected at acceptance rather than discovered when the spooler chokes on it.
from pydantic import BaseModel, ConfigDict, Field, field_validator
PRINTER_TARGET_RE = r"^printer-[0-9]{2,3}$"
class PrintJob(BaseModel):
"""The unit accepted by the print-queue dispatcher on the event floor."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
attendee_id: str = Field(min_length=4, max_length=48)
badge_pdf_ref: str = Field(min_length=1, max_length=512)
printer_target: str = Field(pattern=PRINTER_TARGET_RE)
priority: int = Field(default=100, ge=0, le=1000)
idempotency_key: str = Field(min_length=8, max_length=80)
@field_validator("badge_pdf_ref", mode="before")
@classmethod
def require_pdf(cls, v: str) -> str:
# The floor prints rendered artifacts only; anything else is a bug upstream.
if not str(v).lower().endswith(".pdf"):
raise ValueError("badge_pdf_ref must reference a rendered .pdf artifact")
return v
Each field earns its place. attendee_id is the correlation key threaded through every log line and the dead-letter envelope, bounded to a stable alphanumeric shape and never defaulted. badge_pdf_ref points at the already-rendered artifact from the upstream routing stage; the mode="before" validator runs prior to type checking and rejects anything that is not a PDF, closing the door on a half-formed job reaching a printer. printer_target is pinned to a closed pattern because it selects a specific physical device — an unknown target must fail loudly, not silently pick a default. priority is a bounded integer where a lower number prints sooner, which lets a VIP or a re-drive jump the line deterministically. idempotency_key is the structural guarantee against double-printing: it is derived from the check-in scan upstream and is the exact value the dedupe gate claims, so one scan can occupy a queue exactly once. extra="forbid" ensures an upstream shape change adds a stray field here rather than being silently carried into the spooler.
Deterministic Implementation Link to this section
The dispatcher is Redis-backed for a reason: one printer maps to one sorted set, ordering is the set’s score, depth is its cardinality, and the whole thing survives a dispatcher restart because state lives in Redis, not in process memory. Acceptance is a fixed sequence — check depth for backpressure, claim the idempotency key, then write the scored member — and the spooler pops the lowest score to print next. Every decision emits a structured line keyed by a correlation ID so a job can be traced from acceptance to the thermal head.
from __future__ import annotations
import json
import logging
import time
from typing import Optional
import redis
from pydantic import BaseModel, ConfigDict, Field, field_validator, ValidationError
logger = logging.getLogger("print_queue")
PRINTER_TARGET_RE = r"^printer-[0-9]{2,3}$"
class PrintJob(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
attendee_id: str = Field(min_length=4, max_length=48)
badge_pdf_ref: str = Field(min_length=1, max_length=512)
printer_target: str = Field(pattern=PRINTER_TARGET_RE)
priority: int = Field(default=100, ge=0, le=1000)
idempotency_key: str = Field(min_length=8, max_length=80)
@field_validator("badge_pdf_ref", mode="before")
@classmethod
def require_pdf(cls, v: str) -> str:
if not str(v).lower().endswith(".pdf"):
raise ValueError("badge_pdf_ref must reference a rendered .pdf artifact")
return v
class QueueBackpressure(Exception):
"""Raised when a printer's queue depth is at or above its high-water mark."""
class RedisPrintQueueDispatcher:
"""One sorted set per printer: score orders, cardinality is depth."""
HIGH_WATER = 200 # per-printer queue-depth ceiling
DEDUPE_TTL = 21_600 # 6h — covers a full check-in day
def __init__(self, client: "redis.Redis") -> None:
self.r = client
def _queue_key(self, printer_target: str) -> str:
return f"printq:{printer_target}"
def _dedupe_key(self, idempotency_key: str) -> str:
return f"printq:dedupe:{idempotency_key}"
def accept(self, job: PrintJob, correlation_id: str) -> bool:
"""Enqueue a job; return False if it was a suppressed duplicate."""
key = self._queue_key(job.printer_target)
depth = self.r.zcard(key)
if depth >= self.HIGH_WATER:
logger.warning(json.dumps({
"event": "queue_backpressure", "printer_target": job.printer_target,
"depth": depth, "correlation_id": correlation_id,
"idempotency_key": job.idempotency_key,
}))
raise QueueBackpressure(job.printer_target)
# Idempotency: one badge per key, even under scanner double-taps.
if not self.r.set(self._dedupe_key(job.idempotency_key), correlation_id,
nx=True, ex=self.DEDUPE_TTL):
logger.info(json.dumps({
"event": "duplicate_suppressed", "idempotency_key": job.idempotency_key,
"correlation_id": correlation_id,
}))
return False
# Lower priority number prints sooner; the fractional arrival time
# breaks ties in FIFO order within a priority band.
score = job.priority + (time.time() % 1)
self.r.zadd(key, {job.model_dump_json(): score})
logger.info(json.dumps({
"event": "job_enqueued", "printer_target": job.printer_target,
"priority": job.priority, "depth": depth + 1,
"correlation_id": correlation_id, "idempotency_key": job.idempotency_key,
}))
return True
def next_job(self, printer_target: str) -> Optional[PrintJob]:
"""Spooler pull: pop the lowest-score (highest-priority) job."""
popped = self.r.zpopmin(self._queue_key(printer_target), 1)
if not popped:
return None
raw_member, _score = popped[0]
return PrintJob.model_validate_json(raw_member)
if __name__ == "__main__":
# Verification runs without a live Redis: it proves the contract, not the wire.
job = PrintJob.model_validate({
"attendee_id": "att_44817",
"badge_pdf_ref": "s3://badges/att_44817.pdf",
"printer_target": "printer-02",
"priority": 5,
"idempotency_key": "scan-att_44817-88231",
})
assert job.printer_target == "printer-02"
# The JSON round-trip is what a sorted-set member relies on.
assert PrintJob.model_validate_json(job.model_dump_json()).idempotency_key == job.idempotency_key
# A non-PDF artifact is rejected before it can ever reach a printer queue.
try:
PrintJob.model_validate({
"attendee_id": "att_00001",
"badge_pdf_ref": "s3://badges/att_00001.png",
"printer_target": "printer-01",
"idempotency_key": "scan-att_00001-00001",
})
raise AssertionError("expected a validation error for a non-pdf ref")
except ValidationError:
pass
print("OK:", job.idempotency_key, "priority", job.priority)
Three properties make this safe under floor concurrency. First, the depth check and the dedupe claim happen before the write, so a backpressured printer never accepts and a duplicate never enqueues. Second, the sorted-set score is priority + fractional_arrival, giving a total order that is priority-first and FIFO within a band, so a re-drive at priority=0 jumps ahead deterministically instead of racing. Third, the queue member is the job’s own JSON, so next_job re-validates on the way out — a corrupted member fails at the spooler boundary rather than printing garbage.
Production Debugging & Observability Link to this section
Because acceptance decisions are made and forgotten in milliseconds, the log line is the only forensic record. Every path emits a structured JSON event keyed by correlation_id, which ties back to the originating check-in scan and forward to the print receipt. An enqueued job logs job_enqueued at info with the live depth; a suppressed retry logs duplicate_suppressed; a printer over its ceiling logs queue_backpressure at warning immediately before the dispatcher sheds the job.
{
"event": "queue_backpressure",
"printer_target": "printer-02",
"depth": 200,
"correlation_id": "scan-7f3a9c2e",
"idempotency_key": "scan-att_44817-88231"
}
In a Datadog or ELK pipeline, index these fields by name: correlation_id as the cross-stage trace facet, event as the monitored dimension (a rising queue_backpressure rate means one printer is falling behind the floor), printer_target to attribute the pressure to a specific device, depth as the gauge that drives the backpressure alert, and idempotency_key to prove a duplicate was suppressed rather than double-printed. Jobs shed under sustained backpressure are wrapped with their reason and routed to a dead-letter list for controlled recovery — never dropped — so they can be rebalanced or handed to failover. A saturation alert on depth crossing the high-water mark on any single printer while peers sit low is the earliest signal that load is skewed and a rebalance is due, the exact condition worked in load-balancing jobs across printers.
Performance & Memory Constraints Link to this section
The dispatcher is I/O-bound on Redis, not CPU-bound; the failure modes are queue-depth blow-out and memory growth in the sorted sets, not compute.
| Component | Constraint | Mitigation |
|---|---|---|
| Per-printer sorted set | Unbounded depth blows the scan-to-print budget for every job behind it | Enforce HIGH_WATER at acceptance; raise QueueBackpressure and shed or rebalance rather than grow |
| Redis memory | Each queued member holds the full job JSON; a stuck queue inflates memory | Keep members lean (a badge_pdf_ref, not the PDF); alert on used_memory; drain stuck queues promptly |
| Dedupe key space | One SET NX key per scan for the whole day accumulates |
Bound with a DEDUPE_TTL that just covers the event; let Redis auto-evict on expiry |
| Spooler pull rate | ZPOPMIN throughput caps how fast a printer drains its band |
Pull in small batches per printer; scale printers horizontally, not the queue depth |
| Structured log volume | A duplicate_suppressed storm during a retry loop inflates ingestion |
Sample repeated identical suppressions; keep one exemplar per (printer_target, key) window |
The one most often gotten wrong is treating depth as cosmetic. A queue that grows without a ceiling does not fail loudly — it silently converts a one-second SLA into a ten-second one, and the line backs up before any alarm fires. The high-water mark is what turns that slow failure into a fast, visible one.
Incident Triage Checklist Link to this section
When a printer’s queue stalls or the backpressure alarm fires, work these steps in order. Target MTTR is under fifteen minutes; every step before the last is non-destructive.
- Confirm the symptom class. Check which printer is pressured:
redis-cli --scan --pattern "printq:printer-*" | while read k; do echo "$k $(redis-cli zcard "$k")"; done. One queue far above its peers is skew; every queue climbing is a floor-wide throughput problem. - Peek the stuck head.
redis-cli ZRANGE printq:printer-02 0 0 WITHSCORESshows the lowest-score job and its priority band; a wedged head at an unexpected score usually means a poisoned member or a spooler that stopped pulling. - Check spooler liveness. Confirm the printer is actually draining: compare
zcardnow versus thirty seconds ago. A flat, non-zero depth with no pops is a zombie printer — hand it to On-Site Print Failover rather than clearing the queue. - Rebalance, don’t flush. If the device is healthy but skewed, drain the excess to a peer: pop from the hot queue and re-accept with the peer as
printer_target, preserving theidempotency_key. The full procedure is in clearing a stuck queue at peak check-in. - Validate recovery. Confirm depth returns below the high-water mark and the
queue_backpressurerate falls to zero:redis-cli ZCARD printq:printer-02. Only then re-enable normal routing to that target.
Frequently Asked Questions Link to this section
Why order with a sorted set instead of a plain Redis list?
Because a list gives you FIFO and nothing else, and the floor needs priority. A re-driven badge, a VIP, or a failover job must be able to jump ahead of routine traffic deterministically. Encoding priority + fractional_arrival as a sorted-set score gives a total order that is priority-first and FIFO within each band, and ZPOPMIN pulls the next job in that order in one round trip — a plain list would force a separate priority structure and a race between them.
Where exactly does the double-print get stopped?
At the idempotency gate, before the write. The dispatcher claims printq:dedupe:{idempotency_key} with SET NX, and if the key already exists it returns without enqueuing. Since the key is derived from the check-in scan upstream, a double-tapped barcode or a retried dispatch resolves to the same key and the second attempt is suppressed. A genuine reprint is a different, logged action handled by Badge Reprint Handling, never an accidental duplicate here.
What happens to a job shed under backpressure?
It is not dropped. When a queue is at its high-water mark the dispatcher raises QueueBackpressure and the job is routed to a dead-letter list with its reason and correlation ID, from which it is rebalanced to a healthier printer or handed to failover. Backpressure is a redirect signal, not a discard — the SLA is protected by moving the job, never by losing it.
Related Link to this section
- On-Site Check-In & Print Routing — the parent section that frames how this queue layer connects to kiosk sync, failover, and reprint handling.
- Check-In Kiosk Sync — the upstream stage that turns a raw scan into the trusted check-in event this dispatcher accepts.
- On-Site Print Failover — the sibling stage that drains a dead printer’s queue, consuming the depth and liveness signals this component emits.
- Clearing a Stuck Badge Print Queue at Peak Check-In — the focused runbook for draining and unwedging a jammed queue mid-rush.
- Load-Balancing Badge Jobs Across Multiple On-Site Printers — the deep dive on spreading load so no single queue skews past its ceiling.
Up: On-Site Check-In & Print Routing — the section this queue-orchestration stage sits inside.