Skip to content

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

Maryam Ghorbani requested to merge feat/ticket-pdf-rabbitmq into beta

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 as delivered.
  • 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, the confirmed claim 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.

Merge request reports

Loading