Summary
Currently, DeliverContentsJob processes each delivery sequentially in a while-loop — one email sent, then the next. This is the bottleneck when a large batch of deliveries are due at the same time, since each SMTP call blocks before the next one starts.
Decided approach: ThreadPoolExecutor
After reviewing the codebase, ThreadPoolExecutor is the right fit for a library:
- The job is I/O-bound (SMTP + DB reads) — threads are the correct primitive, not processes
- Zero additional dependencies required, which matters for a reusable library
- No Celery, RQ, or Django-Q required on the adopter's side
What already works in our favour
DatabaseDeliveryQueue.get_next_batch() already uses SELECT FOR UPDATE SKIP LOCKED when claiming delivery schedules, so concurrent threads cannot double-deliver the same email — the DB-level locking is already solid.
One thing to fix before adding threads
next_task() shares a single self._task_iterator across calls. Multiple threads calling it concurrently would race on next(). Fix options:
- Add a
threading.Lock around next() in next_task()
- Or have each worker call
get_next_batch() independently rather than sharing the iterator
The second option is cleaner — each thread pulls its own batch, and skip_locked=True ensures no overlap.
Django DB connection handling
Each worker thread needs its own DB connection. Call django.db.close_old_connections() at the start of each worker function to ensure Django opens a fresh connection per thread rather than sharing one.
Implementation plan
- Make
next_task() / batch fetching thread-safe (see above)
- Add
DELIVERY_WORKERS to the DJANGO_EMAIL_LEARNING settings (default: 1 for backward compatibility)
- Replace the sequential while-loop in
_run_job with a ThreadPoolExecutor(max_workers=DELIVERY_WORKERS)
- Call
django.db.close_old_connections() at the start of each worker
- Document
DELIVERY_WORKERS in the configuration docs
Acceptance criteria
Out of scope
Celery/RQ/Django-Q integration and true async HTTP responses (202 semantics on the API endpoint) are not part of this issue. ThreadPoolExecutor within the management command is sufficient and keeps the library dependency-free.
Summary
Currently,
DeliverContentsJobprocesses each delivery sequentially in a while-loop — one email sent, then the next. This is the bottleneck when a large batch of deliveries are due at the same time, since each SMTP call blocks before the next one starts.Decided approach:
ThreadPoolExecutorAfter reviewing the codebase,
ThreadPoolExecutoris the right fit for a library:What already works in our favour
DatabaseDeliveryQueue.get_next_batch()already usesSELECT FOR UPDATE SKIP LOCKEDwhen claiming delivery schedules, so concurrent threads cannot double-deliver the same email — the DB-level locking is already solid.One thing to fix before adding threads
next_task()shares a singleself._task_iteratoracross calls. Multiple threads calling it concurrently would race onnext(). Fix options:threading.Lockaroundnext()innext_task()get_next_batch()independently rather than sharing the iteratorThe second option is cleaner — each thread pulls its own batch, and
skip_locked=Trueensures no overlap.Django DB connection handling
Each worker thread needs its own DB connection. Call
django.db.close_old_connections()at the start of each worker function to ensure Django opens a fresh connection per thread rather than sharing one.Implementation plan
next_task()/ batch fetching thread-safe (see above)DELIVERY_WORKERSto theDJANGO_EMAIL_LEARNINGsettings (default:1for backward compatibility)_run_jobwith aThreadPoolExecutor(max_workers=DELIVERY_WORKERS)django.db.close_old_connections()at the start of each workerDELIVERY_WORKERSin the configuration docsAcceptance criteria
DELIVERY_WORKERS = 1(default) behaves identically to today — no breaking changeDELIVERY_WORKERS > 1processes deliveries concurrently using threadsskip_locked)DELIVERY_WORKERSis documented in the configuration referenceOut of scope
Celery/RQ/Django-Q integration and true async HTTP responses (202 semantics on the API endpoint) are not part of this issue.
ThreadPoolExecutorwithin the management command is sufficient and keeps the library dependency-free.