DEV Community

JasperFlint6947
JasperFlint6947

Posted on

Fintech Dispatch Pacing: Comparing Rate-Limited Email Queues for SaaS Background Jobs

Short answer: build the fintech shipment fan-out as a rate-limited email sending queue with one durable, idempotent job per subscriber, then pace workers with a shared token bucket; buy lower latency only for the first useful update, and let routine SaaS background jobs consume spare capacity.

That decision separates two concerns that are easy to muddle. The queue protects work through restarts and traffic bursts. The limiter controls when a ready job may call an email provider. Resend, Postmark, and SES can sit behind the same narrow adapter, but no provider choice removes the need to define those two behaviors in the application. “Cheapest” is also workload-dependent: a low per-message charge can lose to excess worker time, retries, or operational effort, while an expensive fast lane is wasteful for an update that may wait ten minutes.

For a concrete case, imagine a fintech product mailing card-shipment updates to customers and delegated account administrators. One carrier event can fan out to many subscribers. The useful engineering target isn't maximum send speed. It is a bounded time-to-first-important-message at a cost the team has chosen explicitly.

What should a rate-limited email queue compare for SaaS background jobs?

Start with latency classes, not provider logos. A “card delivered” notice may deserve the first available capacity because it changes what a customer should do now. A duplicate status summary can wait. Give each class a service objective and a maximum spend, then make the scheduler enforce that policy. This turns latency versus cost from a vague procurement debate into an input to code and evaluation.

The queue contract needs four fields that survive every implementation choice: a stable event identifier, a subscriber identifier, a template version, and a scheduling class. The pair (event_id, subscriber_id) can become the idempotency key. If a worker exits after sending but before acknowledging its job, the retried job still represents the same intended delivery. Exactly-once execution is the wrong promise here; durable intent plus idempotent handling is the useful target. Keep payloads small by storing a reference to the shipment event rather than a rendered email, unless audit requirements demand immutable rendered content. Rendering late picks up current suppression and recipient state; rendering early preserves exactly what was intended at enqueue time. There is no universal winner — regulated review, deletion rules, and template-change policy decide it. The resulting data flow is plain: ingest and validate one carrier event, write the event and its subscriber jobs in a transaction, let workers claim ready jobs, acquire pacing capacity, render, call the configured adapter, and record the terminal outcome. A separate reconciler inspects old nonterminal jobs. This is the notebook-to-prod boundary I care about: every implicit assumption in a small pacing experiment becomes stored state, a measurable transition, or a recovery rule before real recipients enter the loop.

Cron belongs at the edge of this design. It is a time-based job scheduler, so it works for periodic reconciliation, stale-lease recovery, and aggregate reports. It should not be the primary fan-out mechanism for a newly arrived shipment event because polling adds avoidable delay and couples throughput to the polling interval. Event arrival should enqueue work; a cron-triggered reconciler should verify that nothing was missed.

That's enough machinery. Don't turn a mailer into a workflow engine unless the product actually needs branching workflows.

Build the pacing loop before choosing an adapter

This runnable Python example keeps provider I/O abstract and makes the scheduling decision visible. It uses two queues because strict FIFO would let a large batch of routine updates block a newer urgent update. The token bucket permits a controlled burst while enforcing a sustained rate, and a concurrency semaphore separately caps in-flight calls.

import asyncio
import time
from collections import deque
from dataclasses import dataclass
from typing import Awaitable, Callable


@dataclass(frozen=True)
class EmailJob:
    event_id: str
    subscriber_id: str
    address: str
    status: str
    priority: str

    @property
    def idempotency_key(self) -> str:
        return f"{self.event_id}:{self.subscriber_id}"


class TokenBucket:
    def __init__(self, rate_per_second: float, burst: int) -> None:
        self.rate = rate_per_second
        self.capacity = float(burst)
        self.tokens = float(burst)
        self.updated_at = time.monotonic()
        self.lock = asyncio.Lock()

    async def acquire(self) -> None:
        while True:
            async with self.lock:
                now = time.monotonic()
                elapsed = now - self.updated_at
                self.tokens = min(
                    self.capacity, self.tokens + elapsed * self.rate
                )
                self.updated_at = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                wait_seconds = (1 - self.tokens) / self.rate
            await asyncio.sleep(wait_seconds)


async def run_queue(
    jobs: list[EmailJob],
    send: Callable[[EmailJob], Awaitable[None]],
    rate_per_second: float,
    burst: int,
    max_in_flight: int,
) -> None:
    urgent = deque(job for job in jobs if job.priority == "urgent")
    routine = deque(job for job in jobs if job.priority != "urgent")
    limiter = TokenBucket(rate_per_second, burst)
    in_flight = asyncio.Semaphore(max_in_flight)

    async def deliver(job: EmailJob) -> None:
        await limiter.acquire()
        async with in_flight:
            await send(job)

    tasks: list[asyncio.Task[None]] = []
    while urgent or routine:
        selected = urgent.popleft() if urgent else routine.popleft()
        tasks.append(asyncio.create_task(deliver(selected)))
    await asyncio.gather(*tasks)


async def demo_send(job: EmailJob) -> None:
    await asyncio.sleep(0.01)
    print(job.idempotency_key, job.status, job.address)


async def main() -> None:
    sample_jobs = [
        EmailJob("shipment-1042", "customer-7", "a@example.test", "delivered", "urgent"),
        EmailJob("shipment-1042", "admin-2", "b@example.test", "delivered", "routine"),
        EmailJob("shipment-1043", "customer-9", "c@example.test", "in_transit", "routine"),
    ]
    await run_queue(
        sample_jobs,
        demo_send,
        rate_per_second=2.0,
        burst=2,
        max_in_flight=2,
    )


if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

The numbers in the demo are test inputs, not suggested provider limits. Production values must come from the quota assigned to the sending account and from measurements of the workload. If several worker processes each create this in-memory bucket, each process believes it owns the whole allowance. Put the bucket in a shared coordinator, partition a known global allowance among workers, or run one dispatcher that grants permits. Otherwise horizontal scaling quietly multiplies the send rate.

There is another catch: the example creates all tasks immediately, which is readable for three jobs but unsuitable for a million-subscriber fan-out. A production worker should claim a bounded page, keep a bounded local buffer, and replenish it as jobs finish. Backpressure must reach the database instead of merely moving the backlog into process memory.

Claim work without turning retries into duplicates

PostgreSQL can support a compact queue when the existing application already depends on it and the throughput fits the database budget. Workers may select eligible rows with FOR UPDATE SKIP LOCKED, which lets concurrent claimers skip rows currently locked by another transaction. The PostgreSQL documentation also warns that SKIP LOCKED provides an inconsistent view of the data. That is acceptable for distributing queue work; it is not a general-purpose answer for financial reporting.

Claiming should be a short transaction: select a bounded set of ready rows, stamp a lease owner and expiry, commit, and only then perform network I/O. Holding row locks while sending email stretches transaction time around an external dependency. A lease allows a reconciler to return abandoned work to the ready state without guessing whether a live worker is merely slow.

Retries need classification. A response that explicitly says the request should be attempted later goes back to a scheduled state with bounded delay and jitter. A permanent recipient or request failure becomes terminal and visible; blindly retrying it burns capacity and hides a data-quality problem. Ambiguous outcomes require the idempotency contract because the client may not know whether a provider accepted the request. The adapter should preserve a provider receipt when one exists, but the application job key remains the cross-provider identity.

Ordering is often overspecified. Subscribers usually care that “delivered” does not arrive before “shipped,” but they don't care whether two administrators receive mail in the same order. Enforce order per shipment or recipient, not across the entire queue. A global FIFO queue turns one slow or deferred job into everybody's latency.

Fast can be wrong.

For fan-out creation itself, avoid inserting every subscriber job in one huge transaction. Page through subscribers and record a fan-out cursor so creation can resume. The event remains the parent record, making completeness measurable: expected eligible subscribers, jobs created, terminal successes, terminal failures, and jobs still outstanding. Those counts are operational signals, not a promise that mailbox delivery has occurred.

Compare providers through one measured contract

Treat Resend, Postmark, and SES as candidates behind the same application interface. The comparison unit is a complete delivery path under your account configuration, not a marketing headline. Each adapter should accept the same job identity, recipient, template data, and metadata, then return a normalized acceptance receipt or a classified outcome. Keep provider-specific fields at the boundary.

Use an evaluation harness before moving notebook logic into production. Replay synthetic shipment events with no real customer addresses, record queue wait time separately from adapter time, and test at least four shapes: a quiet trickle, one large fan-out, urgent work arriving behind a routine batch, and a worker restart with leased jobs. Assertions should cover duplicate suppression, per-class latency, bounded in-flight work, and recovery of expired leases. This is the same discipline that keeps an AI evaluation from confusing model latency with retrieval latency: measure each stage, or optimize the wrong component.

The decision table can stay small:

Axis Measurement Decision use
Urgent latency enqueue-to-start and enqueue-to-accept percentiles Reserve capacity or raise the urgent share
Routine cost total delivery-path cost per accepted routine job Set batching and worker policy
Saturation ready age, claimed count, and available permits Distinguish backlog from slow adapter calls
Recovery time for an expired lease to become claimable Set lease and reconciliation intervals
Portability adapter-specific fields outside the boundary Estimate the real cost of changing providers

I'm not sure which option is cheapest for a particular SaaS backend without its message mix, account terms, support burden, and required latency. Your mileage may vary. Put current quotes into the harness as configuration and calculate cost for the same replay; don't hard-code a public price into architecture. Node.js and Python implementations should produce the same decision if their queue semantics, quotas, and measurements match. Runtime preference is a team and deployment constraint, not evidence that one provider wins.

The limitation of a database-backed queue is operational coupling. A large mail backlog competes with product queries for storage, connections, vacuum work, and operator attention. Stick with it when volume is bounded, the team already operates PostgreSQL, and transactional enqueue with application state is valuable. Choose a dedicated broker or managed queue when isolation, very large backlogs, or independent scaling matters enough to justify another system. A dedicated queue still needs idempotency, pacing, and reconciliation — it changes the ownership boundary, not the delivery contract.

Ship the policy, then watch the age of work

Deployment should begin below the assigned sending allowance, with a small worker pool and synthetic recipients. Increase capacity only after queue-age and adapter measurements agree with the model. Dashboard ready-job age by priority rather than showing only queue depth: ten old urgent jobs can matter more than ten thousand fresh routine jobs. Track lease expirations, terminal classifications, duplicate suppressions, and fan-out completeness beside latency and cost.

Write the operational checklist as decisions a responder can execute. If urgent age rises while permits are exhausted, inspect the configured allowance and priority allocation. If permits are available but claimed jobs age, inspect adapter latency and worker concurrency. If ready work grows while workers are idle, inspect claim eligibility and lease state. If fan-out totals disagree, resume from the stored cursor rather than replaying the event without bounds.

Prompt and template changes deserve the same controlled rollout as code. Version every email template, include the version on the job, and run snapshot checks on representative shipment states before release. If an AI-generated summary is ever added, keep it outside the scheduling key, pin its prompt and model configuration, and evaluate it separately; token cost and generation latency must not become invisible queue behavior.

The final decision is deliberately unglamorous: define the latency classes, persist one idempotent intent per subscriber, pace from a shared allowance, and choose infrastructure using replayed workload evidence. Provider and language changes are then bounded adapter work instead of a rewrite of delivery semantics.

Sources

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

Rate limits are product policy as much as infrastructure: the queue should state who gets delayed, how retries are classified, and what customer-visible deadline triggers escalation. A simulation with bursty tenants and provider throttling would make fairness and recovery trade-offs measurable.

⌂ 换网址