Skip to main content

Odoo Mass Mailing Batch Timeout — How to Fix It Today 2026

Odoo mass mailing timing out mid-send in 17/18/19? Tune batch size, worker time limit, and queue throttling with the full deep-dive guide safely.

E
ECOSIRE Research and Development Team
|4 مئی، 20266 منٹ پڑھیں1.2k الفاظ|

This article is currently available in English only. Translation coming soon.

You launch an Odoo mass mailing to 10,000 recipients. After 30 minutes, the campaign shows "in progress". After an hour, the worker dies with:

You launch an Odoo mass mailing to 10,000 recipients. After 30 minutes, the campaign shows "in progress". After an hour, the worker dies with:

ERROR: Worker (12345) exceeded limit_time_real (3600), terminating
WARNING: mass.mailing batch incomplete: sent 4327, remaining 5673

Some recipients got the email, some did not, and re-sending risks duplicates. This is a classic batch-timeout problem in Odoo 17.0/18.0/19.0 — fixable with worker tuning, batch sizing, and queue strategy.

Quick Fix

Raise the worker's wallclock limit and lower the batch size:

# odoo.conf
limit_time_real_cron = 14400      # 4 hours wallclock max for cron
limit_time_real = 600              # 10 minutes for HTTP requests
mail_mass_mailing_batch_size = 200 # smaller batches resume faster

Restart Odoo, then resume the campaign:

mailing = self.env['mailing.mailing'].browse(<id>)
mailing.action_send_mail()    # resumes from where it left off

For ongoing or chronic timeout issues, install OCA's queue_job and route mass mailing through it.

Why This Happens

Odoo mass mailing flow:

  1. Campaign creates mailing.trace rows for each recipient.
  2. A cron picks up traces in batches and sends them.
  3. Each send is one SMTP round-trip plus DB writes.

The five common timeout causes:

  1. Worker time limit too low. Odoo's limit_time_real_cron defaults to 1 hour. A 10K-recipient send at 0.5 sec each = 83 minutes — does not fit.
  2. SMTP rate limit. Gmail throttles to 100/minute, SES to ~200/sec — Odoo waits between sends, the wait pushes total time over the limit.
  3. DB contention. Heavy writes to mail.mail and mailing.trace plus other concurrent activity slow each send.
  4. Memory growth during send. As the cron processes more emails, ORM cache grows, RAM bloats, eventually the worker is killed.
  5. DKIM signing or external image embedding adds latency per send. Cumulative latency exhausts the time budget.

Step-by-Step Diagnosis

1. Check campaign status.

SELECT m.id, m.name, m.state, m.sent, m.total
FROM   mailing_mailing m
WHERE  m.id = <campaign_id>;

sent < total with state='sending' for hours = stuck batch.

2. Check mailing.trace for incomplete sends.

SELECT trace_status, count(*) FROM mailing_trace
WHERE  mass_mailing_id = <id>
GROUP BY trace_status;

outgoing rows are unsent. error rows failed for a reason — read failure_reason.

3. Check worker logs around the timeout.

grep "limit_time_real\|exceeded\|terminated" /var/log/odoo/odoo.log | tail

OOM-killed workers mean memory growth, not just timeout. Address with batch tuning.

4. Measure single-send latency. Send to 1 test recipient, time the worker:

import time
start = time.time()
mailing._send([test_recipient_id])
print(f"Per-recipient: {time.time() - start:.2f} sec")

If single sends are slow (over 0.5 sec), reduce send latency before increasing volume.

5. Check provider rate limits. Look at SES SendStatistics or Gmail's API quota. Throttled sends queue, dramatically increasing total time.

Permanent Fix

Tune worker time limits for mass mail:

# odoo.conf
limit_time_real_cron = 14400       # 4 hours per cron
limit_memory_soft = 2147483648     # 2 GB before recycling
limit_memory_hard = 3221225472     # 3 GB before kill
max_cron_threads = 2

For very large campaigns, consider running a dedicated cron-only Odoo instance with its own tuning, separate from web workers.

Reduce batch size and add intra-batch commits:

# In a custom override of mailing.mailing
@api.model
def _process_mass_mailing_queue(self):
    BATCH_SIZE = 100
    pending = self.env['mailing.trace'].search([
        ('trace_status', '=', 'outgoing')
    ], limit=BATCH_SIZE)

    while pending:
        for trace in pending:
            try:
                trace._send()
            except Exception as e:
                trace.write({
                    'trace_status': 'error',
                    'failure_reason': str(e),
                })
        self.env.cr.commit()        # release locks, persist progress
        self.env.invalidate_all()   # cap memory growth

        pending = self.env['mailing.trace'].search([
            ('trace_status', '=', 'outgoing')
        ], limit=BATCH_SIZE)

Smaller batches mean smaller transactions, faster recovery from failure, and more frequent garbage collection.

Use queue_job for proper scaling:

from odoo.addons.queue_job.job import job

class MailingMailing(models.Model):
    _inherit = 'mailing.mailing'

    @job(default_channel='root.mail')
    def _send_one(self, trace_id):
        self.env['mailing.trace'].browse(trace_id)._send()

    def action_send_mail_async(self):
        for trace in self.mailing_trace_ids.filtered(lambda t: t.trace_status == 'outgoing'):
            self.with_delay()._send_one(trace.id)

queue_job processes mails outside the request/cron cycle. Scales horizontally — add queue workers, throughput rises linearly.

Throttle to provider limits:

import time

class MailingMailing(models.Model):
    _inherit = 'mailing.mailing'

    SEND_RATE_PER_SEC = 50   # SES safe default

    def _process_mass_mailing_queue(self):
        last_send = time.time()
        for trace in self.mailing_trace_ids.filtered(lambda t: t.trace_status == 'outgoing'):
            elapsed = time.time() - last_send
            wait = max(0, (1.0 / self.SEND_RATE_PER_SEC) - elapsed)
            if wait:
                time.sleep(wait)
            trace._send()
            last_send = time.time()

Self-throttled sending stays under provider limits. Pair with provider-specific knowledge — SES is 14/sec on cold accounts, 200/sec when warmed.

Resume on partial failure. Mass mailing should be idempotent: if a worker dies mid-batch, the next cron run should pick up from where it left off. Odoo's default behaviour does this via mailing.trace.trace_status='outgoing' filter — verify your custom code preserves this.

How to Prevent It

  • queue_job for mass mailing. Single biggest reliability win for high-volume sending. Decouples send work from cron lifecycle.
  • Test at scale in staging. Send a 5K test campaign in staging before launching 50K in production. Catches batch-size and rate-limit issues before they affect customers.
  • Provider rate awareness. Know your provider's limit. SES, Gmail, Mailgun all publish rate caps. Throttle below the cap with margin.
  • Monitor mailing.trace queue depth. Alert when outgoing count exceeds threshold or grows monotonically. Catches stalls before customers complain.
  • Smaller batches by default. 100 to 500 per batch is the sweet spot for most campaigns. Larger batches risk timeout; smaller batches risk overhead.
  • Use a dedicated sending domain. Improves deliverability (warm-up isolated from transactional traffic) and lets you set per-domain rate limits at the provider.

Frequently Asked Questions

How fast can Odoo realistically send mass mail?

Single-server, single-cron-thread, with SES and a warmed sending domain: 10K mails in about 8 minutes (around 20/sec including DB and DKIM overhead). With queue_job and 4 workers, around 75/sec sustained. Beyond that requires dedicated infrastructure or a transactional service like SendGrid running alongside Odoo.

Should I use Odoo's built-in mass mailing or an external service?

For under 50K/month: Odoo's built-in is fine. Above that, integrate a transactional service like SendGrid or Mailgun via API. The OCA mass_mailing_partner and similar modules provide hooks. Hybrid is common: transactional via SES, marketing via dedicated provider.

Can I pause and resume a mass mailing?

Yes. Pause: change state from sending to draft. Resume: re-run action_send_mail(). The pending traces in outgoing state pick up where they left off. Avoid editing the campaign content between pauses — partial recipients see different versions.

How do I avoid sending duplicates after a crash?

Idempotent design. Each recipient gets one mailing.trace row. The send loop only processes traces with state='outgoing'. Successful sends update to state='sent'. A crash leaves rows in outgoing; resumption picks them up. As long as your custom send code respects this state machine, duplicates are impossible.


Need help with a tricky Odoo error? ECOSIRE's Odoo experts have shipped 215+ modules — get expert help.

شیئر:
E

تحریر

ECOSIRE Team

Technical Writing

The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.

ECOSIRE

Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں

آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔