feat(tickets): deliver the e-ticket PDF from RabbitMQ, drop the poller
Wires the bot's e-ticket delivery to the RabbitMQ order.ticketed event and removes the
30-second polling fallback.
Where this starts
flight publishes order.ticketed on order.exchange with the ticket's pdfUrl
(flight b1577d1). The bot has bound that key since 77e2c6d but has never actually run it —
beta leaves RABBITMQ_URI empty on purpose, and e-tickets are delivered instead by a poller that
re-scans /orders every 30s for every paid booking (23a319a).
What the event path has to survive
A naive "push body.pdfUrl" loses tickets. Three things in the publisher's behaviour force the
design here:
1. The event can arrive with no PDF. generateAndPersistTicketPdf returns null when the
PDF service fails, and the publisher sends pdfUrl: '' rather than block issuance. Its cron
regenerates the file within ~10 minutes and does not re-publish, and TICKETED is emitted
exactly once behind an atomic flip — so that single event is the only one we will ever get for
that booking. The old handler sent a text line and marked the row delivered; with the poller
gone that is a ticket the user never receives.
Now the handler resolves the url itself (cart → order → ticket jwt → pdf url — the poller's own lookup), and while it is still missing, parks the event:
order.ticketed ──▶ ai-bot-events ──▶ handler
▲ │ no PDF yet
│ ▼
└── expires ── ai-bot-events.retry (no consumer; per-message TTL,
(DLX) dead-letters back after 300s)
Nothing consumes the retry queue — each message carries its own TTL and is dead-lettered back
onto ai-bot-events when it expires. The wait lives in the broker, survives a restart, and needs
no timer in this process. 6 attempts × 300s ≈ 30 min, spanning ~3 runs of the flight cron.
Per-message TTL rather than x-message-ttl on the queue, so retuning the delay can never
clash with the queue's existing arguments (a redeclare mismatch is a channel error and would take
the consumer down).
2. The queue is at-least-once. Delivery claims the booking row before pushing (the poller's compare-and-set); a redelivered event cannot send the PDF twice, and a push that then fails releases the claim for the next attempt.
3. Dead-lettering rewrites routing_key to the DLX key, so a retried message no longer says
which event it is. The original travels in a header, with the payload's own event field as a
fallback.
A bug this surfaced
claim_ticket_delivery required processing|link_issued. But order.confirmed lands first
and marks the row confirmed — so once delivery goes through the claim, the normal event order
would reject every e-ticket in silence. It now excludes only delivered. The poller never hit
this because confirmed rows were outside its pending query too; it simply never delivered those
tickets, and nothing noticed.
Also
- Out of retries, the user is told "your ticket is issued, the file is coming" (en/ar/fa) instead
of getting silence; the row stays
processing, not closed asdelivered. - A consumer that fails to start is now an ERROR with a traceback, not a warning. With no
poller behind it, that failure means no e-ticket delivery at all and must not hide behind a
green
/health.
Deploy note (this MR does not work without it)
RABBITMQ_URI must be set on the stack — it is currently empty on beta by deliberate choice
(23a319a). With it unset, a paid booking is confirmed to the user and then never followed by a
ticket. Noted in docker-compose-beta.yml and in the config comment.
docker-compose-production.yml lives on main and needs the same note when this reaches it.
Tests
38 passing, offline:
-
test_ticket_delivery.py— url resolution, exactly-once under redelivery, claim release on a failed push, theconfirmedclaim above, multi-leg tickets. -
test_events_handler.py— url from the event vs. from the lookup, retry while the PDF is missing, retry on a failed push, duplicate, non-http url, final-attempt copy, and that a bug in our own delivery code acks rather than parking the message forever. -
test_consumer_binding.py— routing-key recovery after a retry round trip, attempt counting.
The rest of the suite needs Python 3.14 + archipy; neither would install on this machine (Docker Hub is unreachable from it), so it was not run — worth a CI/beta run before merge.