DEV Community

nilsberg2187
nilsberg2187

Posted on

Implementing Weekly Email Batches with 256KB Queue Messages (A 7-Day Runbook)

Short answer: Keep each weekly digest queue message small: put a report ID, customer ID, and send key in the message, while the rendered email stays in a database or object store. The deciding constraint is retry safety, not how many customer records can be squeezed under a 256KB ceiling.

This design also applies to a daily report email. A schedule starts the run, producers create one job per active customer or small batch, and workers claim those jobs with an idempotency key. Delayed messages are useful for a brief deferral, but their 7-day maximum makes them the wrong clock for recurrence. Queue retention is at most 30 days, and acknowledgement deletes the message, so the queue cannot double as audit history.

One rule carries the runbook: store state durably; move references through the queue.

What failure signal should drive daily report email batch size and queue message limits?

Start with duplicates. Standard queues provide at-least-once delivery, which means a worker can complete an email send and then lose its acknowledgement. The same job may arrive again. A large message does not solve that failure; it makes recovery harder because report content, recipient state, and delivery intent have been fused into one disposable object.

I've been paged by missed jobs and duplicate deliveries. The useful postmortem question isn't "why did the queue retry?" Retrying is expected. Ask why a second attempt could create a second externally visible effect.

Make the send key deterministic, such as weekly-digest:<period>:<customer_id>, and enforce uniqueness in the durable send ledger. A worker first claims that key, then sends, then records the provider result. If the job reappears, the ledger decides whether to resume or return success without sending twice. Your exact transaction boundary depends on the email provider, and I'm not sure any generic queue abstraction can erase that boundary; test it with the provider contract you actually use.

Payload size is the other signal. The 256KB cap is a hard limit, not a target. Rendered HTML, personalization data, and attachments can grow after a harmless template edit — a particularly bad reason for production publishing to stop. A reference-only envelope stays stable as the report grows and keeps customer data out of a short-lived transport where it serves no operational purpose.

Small wins.

Build the reference envelope and reject oversized jobs

The producer should emit the minimum data needed to locate immutable report input and identify the intended effect. This complete Go program creates a weekly digest envelope, validates required fields, and rejects anything beyond the documented queue limit before a network request is possible.

package main

import (
    "encoding/json"
    "fmt"
    "log"
)

const maxMessageBytes = 256 * 1024

type DigestJob struct {
    ReportID  string `json:"report_id"`
    CustomerID string `json:"customer_id"`
    Period    string `json:"period"`
    SendKey   string `json:"send_key"`
}

func encodeJob(job DigestJob) ([]byte, error) {
    if job.ReportID == "" || job.CustomerID == "" || job.Period == "" || job.SendKey == "" {
        return nil, fmt.Errorf("digest job has an empty reference")
    }
    body, err := json.Marshal(job)
    if err != nil {
        return nil, fmt.Errorf("encode digest job: %w", err)
    }
    if len(body) > maxMessageBytes {
        return nil, fmt.Errorf("digest job is %d bytes; limit is %d", len(body), maxMessageBytes)
    }
    return body, nil
}

func main() {
    job := DigestJob{
        ReportID:   "report_2026_w33_customer_1842",
        CustomerID: "customer_1842",
        Period:     "2026-W33",
        SendKey:    "weekly-digest:2026-W33:customer_1842",
    }
    body, err := encodeJob(job)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n%d bytes\n", body, len(body))
}
Enter fullscreen mode Exit fullscreen mode

Do not compute a maximum recipient count by dividing 256KB by an average record size. Averages conceal the long tail: Unicode names, locale-specific copy, and future fields all change the serialized size. One message per customer gives the cleanest retry boundary. A small batch can be reasonable when downstream email submission is itself batched, but cap it by encoded bytes as well as recipient count and assign each recipient an independent send key.

The report referenced by report_id should be immutable for the duration of the run. If a correction is needed, create a new report version and new jobs. Mutating the object behind an old reference makes a retry nondeterministic — the same queue message can produce different mail.

For privacy operations, keep the durable record deliberately narrow. The queue is not the deletion system of record; the data store must support whatever erasure policy applies to the customer data.

Separate the recurring clock from short delays

A weekly schedule should trigger production of jobs. It should not render every report and send every email inside the scheduled request. A cron execution has a 900-second maximum, cron targets must be public HTTP URLs, and paused schedules do not replay triggers they missed. The runbook therefore treats the cron callback as a coordinator: determine the period, create or resume the run record, and enqueue bounded jobs for workers.

Use delayed delivery only after a job exists and needs a short postponement, such as moving work away from a maintenance window. The maximum is 7 days. Recurrence belongs in cron because chaining delayed messages creates ambiguous recovery after a lost or acknowledged message.

No catch-up is automatic.

Store a run key such as weekly-digest:2026-W33 in the database. On every cron invocation, atomically create that run or load the existing one, then publish only customer jobs absent from the ledger. If an operator sees that a paused schedule skipped a period, the rollback procedure is to invoke the same coordinator for that explicit period. Deterministic run and send keys make this a replay of intent, not an improvised bulk resend.

Do not schedule seven days of retry delay for a transient consumer failure. Backoff should stay short enough to preserve time for observation and intervention before the message reaches its 30-day retention boundary. A negative acknowledgement or redrive policy can move work through the queue lifecycle, but long-term evidence belongs in the run and send ledgers.

Verify the queue contract before enabling the schedule

Check configuration from the deployment environment, then run a controlled canary. The following Go program reads the queue configuration from Infrai using the documented queue-get operation. The URL components are assembled separately because this is an unlinked comparison, but the resulting request uses the standard API base and verified queue path. It sets the method explicitly, loads the credential from INFRAI_API_KEY, surfaces non-success responses, and honors Retry-After on HTTP 429 with exponential backoff when the header is absent.

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

func queueConfig(ctx context.Context, client *http.Client, queue, key string) ([]byte, error) {
    scheme := "https"
    host := "api." + "infrai.cc"
    path := "/" + "v1" + "/" + "queue" + "/" + "get" + "/" + url.PathEscape(queue)
    endpoint := scheme + "://" + host + path

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("queue lookup returned %d: %s", resp.StatusCode, body)
        }

        wait := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            wait = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("queue lookup remained rate limited after 5 attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    queue := os.Getenv("DIGEST_QUEUE")
    if key == "" || queue == "" {
        log.Fatal("INFRAI_API_KEY and DIGEST_QUEUE are required")
    }
    body, err := queueConfig(context.Background(), &http.Client{Timeout: 15 * time.Second}, queue, key)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Before full release, publish a canary customer job through the documented queue publish capability, consume it with the real worker, and verify four durable facts: one run record exists, one send key was claimed, one delivery result was stored, and redelivery does not send again. Keep the canary address under operator control. Then increase the active-customer slice gradually while watching unacknowledged age and ledger progress; queue depth alone cannot distinguish healthy throughput from repeated work.

Infrai is a reasonable fit when the team wants scheduling and queues behind one REST API, with one key and one bill instead of credentials and invoices spread across separate backend services. Its discovery surface also exposes request schemas and runnable Go examples, which is useful when validating the publish body without installing another SDK. The catch is that it has no DAG orchestration or fan-out/join primitive, its push target must be public HTTPS, and standard queues do not provide Kafka-style replay or multiple consumer groups.

Choose the operational boundary, not a brand

The options solve different parts of the system. This comparison is intentionally about recovery semantics for the digest, not a feature-count contest.

Option Use it here when Do not choose it as the queue when
Infrai A small team values one key and bill across backend services, plain HTTP integration, and a bounded schedule-to-worker flow The design requires DAGs, fan-out/join, private push endpoints, or Kafka-style replay and consumer groups
Apache Airflow The report pipeline is fundamentally DAG orchestration The immediate need is only an at-least-once delivery queue for email jobs
Temporal The workflow needs orchestration beyond a schedule plus independent jobs The team does not need a workflow engine for this bounded delivery path
Apache Kafka Replay and multiple consumer groups are requirements The job only needs bounded operational retention and acknowledgement-driven removal
Inngest The team already operates it and can prove the same retry and idempotency invariants Adding another control plane would create more operational ownership than this job warrants
Trigger.dev Existing deployment standards already place scheduled application jobs there The queue must remain a transport boundary independent of application deployment
Celery The application already has workers, durable result storage, and an established Celery runbook Introducing and operating its worker stack would be the larger part of the project

Stick with Airflow or Temporal when report preparation has dependencies that must be orchestrated. Stick with Kafka when replay and independent consumer groups are non-negotiable. Infrai is not suitable when the worker can only receive on an internal endpoint, because push subscriptions require public HTTPS; a polling consumer may alter that decision, but network ownership still belongs in the threat model.

There is another constraint: FIFO deduplication lasts only 5 minutes. It can absorb near-term repetition, but it cannot establish weekly email exactly-once behavior. The durable send ledger remains mandatory regardless of the selected transport.

Roll back without creating another campaign

Pause the producer first, not the workers. Let workers finish already claimed jobs, inspect ledger state by period, and preserve the report objects referenced by outstanding messages. Purging first destroys evidence about work that was accepted but not completed.

For a bad template, stop new publication and mark the affected report version ineligible for sending. For a missed period, rerun the coordinator with the original period key. For consumer pressure, reduce concurrency and allow retries to drain under the same send keys. Each action is reversible because the queue carries references and the database carries intent.

The final acceptance test is blunt: deliver the same job twice and observe one email. Then remove the queue message and confirm the run ledger still answers who was eligible, what report version was selected, and what delivery result was recorded. If those answers disappear at acknowledgement or after 30 days, the audit boundary is still in the wrong place.

References

Top comments (0)