Derive the queue, do not store it: resumable email campaigns
- Published on
- • 4 mins read•--- views
Sending one email is easy. Sending forty thousand is a different problem, and the difficulty is not throughput. It is what happens when the run stops halfway.
Because it will. A cron tick times out. PHP hits its memory limit. The SMTP relay starts refusing connections. Someone deploys. At that point you have sent an unknown number of messages, and you have to decide what to do next.
The two usual answers, and why both are bad
Restart from the beginning, and everyone who already received the message receives it again. On a list of forty thousand, that is a mass duplicate send, an unsubscribe spike, and a spam-complaint rate that follows your domain around for months.
Keep a cursor: store "last processed row" and resume from there. This works until the audience changes mid-run. Someone unsubscribes, someone new joins the segment, and the cursor now points at a position in a list that has shifted underneath it. You skip people, or you repeat them, and you cannot tell which.
Both fail for the same underlying reason: they treat progress as the thing to remember. Progress is a fact about the run, and runs die.
Derive it instead
The durable fact is not "how far did we get". It is "this person has been sent this campaign": one row, written at the moment of sending, that survives the death of the run that wrote it.
Once that record exists, the pending queue does not need to be stored at all. It is a query:
SELECT c.ID AS c_id, c.title, c.content, u.ID AS u_id, u.user_email
FROM wp_users u
JOIN mm_user_category uc ON u.ID = uc.user_id
JOIN mm_campaign_category cc ON uc.category_id = cc.category_id
JOIN mm_campaigns c ON cc.campaign_id = c.ID
LEFT JOIN mm_user_campaign d ON u.ID = d.user_id AND c.ID = d.campaign_id
WHERE d.status IS NULL
AND c.is_active = 1
LIMIT 19
The LEFT JOIN plus IS NULL is the whole idea: subscribers who have no delivery row for this campaign. Everything else follows from it.
Crash recovery comes free: the next tick asks the same question and gets the remaining people, so nobody has to know a run died. Double sends become impossible rather than merely unlikely, because a person with a delivery row cannot be selected and there is no flag to forget to set.
Audience changes look after themselves too. Someone added to the segment today appears in tomorrow's query, someone removed disappears, and there is no cursor to invalidate. The state also stays inspectable: "who has not received this yet" is a query anyone can run rather than an internal counter you have to trust.
Pacing belongs next to it
The same loop is where you keep the send inside the host's limits:
$send_per_time = (int) get_option('mm_send_amount_per_time'); // 19
$sleep = (int) get_option('mm_send_sleep_time'); // 3_000_000 µs
foreach (UserCampaignPendingDTO::load_pending($campaign_id, $send_per_time) as $pending) {
usleep($sleep);
UserCampaignPendingMail::send_pending($pending, true);
}
A fixed batch per tick with a pause between messages. Unremarkable code, and it only works because the queue is derived. With a cursor, sleeping inside the loop widens the window in which a crash leaves the cursor lying about where you were.
What it costs
Every tick runs a five-table join. On a large list that needs indexes on the pivot tables and on the delivery status, and it costs more per tick than reading a cursor.
The delivery table grows as subscribers multiplied by campaigns. That is the price of the guarantee, and it doubles as the reporting table, since pending, sent and failed per campaign come out of those same rows.
A send that succeeds but fails to record will be retried. That is the correct direction to fail in, but it means the record must be written immediately after the send rather than batched at the end of the run.
The general shape
When you need to know what work remains, derive it from what has been completed instead of recording where you stopped. Completed work is durable and monotonic; position in a run is neither.
This is the same reasoning behind idempotent message consumers, reconciliation over event replay, and "desired state versus current state" in deployment tooling. In each case the system stops depending on remembering what it did, and starts depending on being able to look at what is true.
Related
Projects

CactusPlay - Game shop run by a vetted contractor network
Before: CIS buyers compared PlayStation and Xbox prices by hand across scattered sellers, and every order had to be filled by the founders themselves. After: a shop with a live catalogue and a CRM any vetted contractor with Turkish payment details could join to take orders and earn on them. Result: 20,000+ positions grabbed hourly, price changes reaching the shop and Yandex.Market untouched, and fulfilment no longer capped by one pair of hands.
Posts
Analytics counters do not belong in theme files
A tracking snippet pasted into a template disappears with the next theme update, competes with the page for the browser, and double-counts when someone pastes it twice. All three have the same fix.
Make delivery a consequence of payment, not a task someone performs
Digital goods delivered by hand are limited by working hours and protected only by attention. Moving delivery onto order events changes what the system guarantees, and the API is what stops a second channel becoming a second implementation.
Pricing a catalogue against a store you do not control
Reselling means your prices depend on someone else changing theirs. Fetching on page load takes your shop down with theirs; a nightly export goes stale. The middle path is a cache with a pricing strategy behind it.
Open for contract collaboration
I am available for contract-based collaboration. If you have an interesting project idea, schedule a call via Calendly.
Schedule a 30-min call