feat(tickets): deliver the e-ticket PDF from RabbitMQ, drop the poller
flight publishes order.ticketed on order.exchange with the ticket's pdfUrl
(flight b1577d1). The bot already bound that key but has never run it: beta
leaves RABBITMQ_URI empty, and e-tickets were delivered instead by a 30s poller
that re-scanned /orders for every paid booking (23a319a4). This makes the event
the delivery path and removes the poller.
What the event path has to survive, which a naive "push body.pdfUrl" does not:
-
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 one event is the only one we will ever get for that booking. The old handler sent a text line and marked the row 'delivered', which with the poller gone would mean the user never receives their ticket. Now: resolve the url ourselves (cart -> order -> ticket jwt -> pdf url, the poller's own lookup), and while it is still missing, park the event inai-bot-events.retryand try again. That queue has no consumer — each message carries its own TTL and is dead-lettered back ontoai-bot-eventswhen it expires, so the wait lives in the broker, survives a restart, and needs no timer in this process. 6 attempts x 300s covers ~3 runs of the flight cron. Per-message TTL (not x-message-ttl on the queue) so retuning the delay can never clash with the queue's existing arguments. - The queue is at-least-once. Delivery now claims the booking row before pushing (the poller's compare-and-set), so a redelivered event cannot send the PDF twice; a push that then fails releases the claim for the next attempt.
-
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
eventfield as a fallback. - claim_ticket_delivery now excludes only 'delivered'. It required processing|link_issued, but order.confirmed lands FIRST and marks the row 'confirmed' — under the claim, the normal event order would have rejected every e-ticket in silence. (The poller never hit this: 'confirmed' rows were outside its pending query too, which is a bug it papered over by never being the path that mattered.)
- Out of retries, say so. The user gets "your ticket is issued, the file is coming" (en/ar/fa) instead of silence, and the row stays 'processing' rather than being closed as delivered.
Also: the consumer failing to start is now an ERROR with a traceback, not a warning — with no poller behind it, a consumer that didn't bind means no e-ticket delivery at all, and that must not hide behind a green /health.
Requires RABBITMQ_URI to be SET on the stack (it is currently empty on beta by deliberate choice, per 23a319a4) — noted in docker-compose-beta.yml and the config. docker-compose-production.yml lives on main and needs the same note.
Tests: evals/tests/test_ticket_delivery.py (resolve + exactly-once + the 'confirmed' claim), test_events_handler.py (event-vs-lookup url, retry while missing, retry on a failed push, duplicate, final-attempt copy), and test_consumer_binding.py (routing-key recovery after a retry round trip, attempt counting). 38 pass. The rest of the suite needs Python 3.14 + archipy, neither of which would install here (Docker Hub is unreachable from this machine), so it was not run.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01Nv58HFwuCGj53GJuvDHBeL