Skip to content

Moved newsletter campaign sending onto the async message queue - #1233

Merged
fballiano merged 10 commits into
mainfrom
newsletter-queue-on-message-queue
Aug 10, 2026
Merged

Moved newsletter campaign sending onto the async message queue#1233
fballiano merged 10 commits into
mainfrom
newsletter-queue-on-message-queue

Conversation

@fballiano

@fballiano fballiano commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1226: the newsletter queue was the last hand-rolled queue in the mail path. Sending was newsletter_send_all walking 3 campaigns x 20 subscribers every 5 minutes, so a blast could not exceed 240 recipients/hour per campaign, and a failed send was logged and lost.

Starting, resuming or scheduling a campaign now dispatches a Mage_Newsletter_Model_Queue_SendMessage (delayed until queue_start_at when that is in the future). The handler sends one batch and chains the next, so a campaign drains as fast as the worker allows and each batch gets the queue's retries, backoff and failure grid. Batch size is configurable under Newsletter > Sending Options (default 100).

newsletter_send_all keeps its name and schedule but only queues a batch for due campaigns, as a safety net for chains lost with a worker and for campaigns scheduled before this existed; newsletter_queue/sending does the same for hosts driving it by URL. Delivery is at-least-once, so a per-campaign lock keeps a redelivered or safety-net message from sending a batch twice, and the dedupe key keeps repeat dispatches from piling up.

Both the campaign batches and the newsletters themselves go to a newsletter queue, so a blast can be given a worker of its own (queue:work --queue=newsletter) instead of delaying transactional mail; Mage_Core_Model_Email_Template gained a queue name to allow that.

Two behavior changes worth knowing: a campaign flips to Sending when its first batch leaves instead of staying Not Sent until it finishes, and addOnlyForSendingFilter() now matches a start date of exactly now (<=), agreeing with the handler's own readiness check.

No schema change, no data migration: campaigns, recipients and problems keep their tables.

tests/Backend/Integration/Newsletter/QueueSendTest.php covers dispatch and dedupe, the delayed start, a batch marking its recipients and chaining the next, finishing on the last batch, paused campaigns, the lock, and the cron picking due campaigns only.

Note

Developed with the help of AI.
As part of our commitment to GenAI transparency, we flag pull requests produced with AI assistance alongside human work. As with every change in Maho, a maintainer reviews and validates it before merge, we never merge purely AI-generated changes. See the GenAI transparency section for details.

Campaigns were sent by newsletter_send_all walking 3 queues x 20
subscribers every 5 minutes, capping a blast at 240 recipients per hour
per campaign, with no retry when a send failed.

A campaign now dispatches a Mage_Newsletter_Model_Queue_SendMessage when
it is started, resumed or scheduled (delayed until its start date), and
its handler sends one batch and chains the next, so the campaign drains
as fast as the worker allows. Batch size is configurable under
Newsletter > Sending Options, defaulting to 100 recipients per message.

The cron keeps its name and schedule but only queues a batch for due
campaigns that have none, as a safety net for chains lost with a worker
and for campaigns scheduled before this existed. A per-campaign lock
keeps a redelivered or safety-net message from sending a batch twice,
and the dedupe key keeps repeat dispatches from piling up.

Both the campaign batches and the newsletters themselves go to a
'newsletter' queue, so a blast can be given a worker of its own instead
of delaying transactional mail; Mage_Core_Model_Email_Template gained a
queue name for that. Campaigns also flip to Sending when their first
batch leaves rather than staying Not Sent until the end.
A campaign scheduled for a future date was dispatched at save time as a
delayed message, which parked a pending row in the queue for as long as
the wait lasted and made a reschedule depend on a dedupe key carrying the
start date. scheduleSending() now dispatches only once the start date has
come; the newsletter_send_all cron picks up the rest when their time
arrives, and the dedupe key is back to the campaign id.

The recipient snapshot moves with it: setStores() no longer materializes
the audience on every save of a draft, the first batch does it instead,
so the recipients are the ones subscribed when the campaign goes out.
Linking them is now a chunked bulk insert rather than a row-per-insert
loop, and the grid reports the projected audience for a campaign that has
not started.

Also fixed along the way: email:queue:process and email:config:show only
looked at the email queue, so newsletter mail was never drained on hosts
without a resident worker, and the batch handler decided whether to chain
the next batch from a stale copy of the campaign status.
Deferring the recipient snapshot to the first batch left three ways for a
campaign to lose its audience:

- setStores() still deleted every unsent queue_link but no longer re-added
  them, so saving a paused campaign stranded whoever had not been mailed yet.
  It now refuses outright once the campaign is past STATUS_NEVER, which also
  stops a save from wiping the queue_store_link rows: the form disables those
  inputs, so a browser posts nothing and the empty set was being applied.
- materializeRecipients() was gated on STATUS_NEVER, but start and resume
  reach STATUS_SENDING before any batch runs, so the first batch found no
  links and finished the campaign with nobody mailed. It self-guards on an
  empty store list and on links already existing, so the call is unconditional.
- addSubscribersToQueue() rolled back and swallowed the exception, which now
  means a silently empty audience instead of a save-time error. It rethrows so
  the queue message retries with backoff.

The admin save applies start_at, stores, customer_segments, subject, sender,
text and styles only while the campaign is in STATUS_NEVER, and refuses a
request that carries any of them afterwards; only resuming stays available.

removeSubscribersFromQueue() committed and rolled back a transaction it never
began, and swallowed the exception on top. It is a single delete.
@fballiano fballiano added this to the 26.9.0 milestone Aug 7, 2026
@fballiano fballiano added the ✨ ai-assisted Developed with the help of AI label Aug 7, 2026
setStores() writes outside $_data, so save() short-circuited on
_hasModelChanged() and never reached _afterSave(). getStores() also read
an empty $_stores as 'not loaded' and re-fetched stale rows.
- email:queue:clear and email:config:show now cover the newsletter queue as
  well as the email one, so a failed blast can be cleared and pending
  newsletter mail is reported
- chunk the probe and the row build in addSubscribersToQueue(), not just the
  insert, so materializing a large audience no longer builds a multi-megabyte
  statement
- a batch continuation carries the campaign dedupe key without enforcing it,
  keeping the cron sweep from starting a rival chain every run
- index newsletter_subscriber on (store_id, subscriber_status) so the queue
  grid's projected-audience count is answered from the index
- the subscribers_total filter compares the same expression the grid renders,
  instead of counting queue_link rows the grid may not be showing
# Conflicts:
#	lib/MahoCLI/Commands/EmailConfigShow.php
#	lib/MahoCLI/Commands/EmailQueueProcess.php
#	tests/Backend/Integration/Queue/DbTransportTest.php
- Serialized the dedupe check-and-insert on a core lock so concurrent
  dispatches of one campaign cannot start two chains
- Failure-transport sends are never deduped against a live chain
- Froze campaign content at the model layer once sending has started
- Snapshot the audience with a single INSERT ... SELECT statement
- Load the unsent batch first and materialize only when it comes back empty
- Finish a campaign whose audience is an exact multiple of the batch size
- Grid recipient count falls back to the projected audience for started
  and paused campaigns
- Dropped the redundant newsletter_subscriber store_id index
@fballiano
fballiano merged commit 4575f34 into main Aug 10, 2026
26 checks passed
@fballiano
fballiano deleted the newsletter-queue-on-message-queue branch August 10, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ ai-assisted Developed with the help of AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant