Skip to content

feat(tickets): deliver the e-ticket PDF from RabbitMQ, drop the poller

Administrator requested to merge beta into main

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 in ai-bot-events.retry and try again. That queue has no consumer — each message carries its own TTL and is dead-lettered back onto ai-bot-events when 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 event field 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

Merge request reports

Loading