Skip to content
About
Main content

A DLQ that does not become an outage amplifier

Simulate a modelNeeds Python

Problem

A is meant for records that can never succeed: malformed input, a schema the cannot read. A naive policy (three attempts, then the DLQ) cannot tell those from records that failed because the downstream was briefly unavailable. During an outage every record fails three times, so good records pour into the DLQ, and when someone them later they are applied out of order.

Write a small consumer with a retry-then-DLQ policy. Feed it 10,000 records where (a) 0.5% are malformed, then (b) after record 5,000 the downstream function starts raising a for every call for 30 seconds. Run it twice: with a naive policy (three attempts, then DLQ) and with a classified policy (transient errors with backoff and never go to the DLQ; malformed records go to the DLQ at once; if more than 2% of the last 1,000 records failed, pause and alert). Report the DLQ size and the number of records processed out of order in each run.

The question. How many good records does each policy send to the dead-letter queue during the outage, and how many end up applied out of order?


Predict

During the 30-second outage, how many good records does the naive policy (three attempts, then the DLQ) send to the dead-letter queue?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    a-dlq-that-does-not-become.py61 linesDownload
    View the code
    a-dlq-that-does-not-become.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """A DLQ that does not become a second data loss.
    
    10,000 records, 0.5% malformed; after record 5,000 the downstream times out
    on every call for 30 seconds. A naive policy (three attempts, then the
    dead-letter queue) against a classified one (transient errors retry with
    backoff and never dead-letter; malformed records dead-letter at once; pause
    and alert when more than 2% of the last 1,000 failed).
    """
    import argparse, json, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--records", type=int, default=10_000, help="Records.")
    p.add_argument("--malformed", type=float, default=0.005, help="Share of malformed records.")
    p.add_argument("--rate", type=float, default=100, help="Records per second offered.")
    p.add_argument("--outage", type=float, default=30, help="Seconds the downstream times out, starting at record 5,000.")
    p.add_argument("--fail-ms", type=float, default=1.0, help="Milliseconds a failing call takes to raise its timeout error.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    random.seed(args.seed)
    bad = set(random.sample(range(args.records), int(args.records * args.malformed)))
    start = 5_000 / args.rate                                         # the outage starts when record 5,000 is due
    
    
    def run(policy):
        t, dlq, applied, recent, paused_s = 0.0, [], [], [], 0.0
        for r in range(args.records):
            t = max(t, r / args.rate)
            while True:
                down = start <= t < start + args.outage
                if r in bad:
                    if policy == "classified": dlq.append(r); break        # malformed: no retry can fix it
                    for attempt in range(3): t += 0.05
                    dlq.append(r); break
                if not down: applied.append(r); recent = (recent + [True])[-1000:]; break
                if policy == "naive":
                    t += 3 * args.fail_ms / 1000                             # three quick failures, then give up
                    dlq.append(r); recent = (recent + [False])[-1000:]; break
                # classified: a transient error; back off and retry the same record (order kept)
                recent = (recent + [False])[-1000:]
                if recent.count(False) > 20: paused_s += 1.0               # > 2% of the last 1,000: pause, alert
                t += 1.0
        out_of_order = sum(1 for a, b in zip(applied, applied[1:]) if b < a)
        late = len([x for x in dlq if x not in bad])
        return {"policy": policy, "dlq": len(dlq), "good_records_in_dlq": late, "applied": len(applied),
                "applied_out_of_order": len(dlq) - len(bad) if policy == "naive" else out_of_order}
    
    
    rows = [run("naive"), run("classified")]
    for r in rows:
        print(f"{r['policy']:<10} DLQ {r['dlq']:>5}  (good records in it: {r['good_records_in_dlq']:>5})   applied {r['applied']:>6}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"policies": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run a-dlq-that-does-not-become.py
    uv run a-dlq-that-does-not-become.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--records10,000 Records.
--malformed0.005 Share of malformed records.
--rate100 Records per second offered.
--outage30 Seconds the downstream times out, starting at record 5,000.
--fail-ms1 Milliseconds a failing call takes to raise its timeout error.
--seed0 Random seed.

Our run

Dead-letter queue after a 30-second outage

Dead-letter queue after a 30-second outage
PolicyIn the DLQGood records in itAppliedApplied out of order
naive3.03K2.98K6.97K2.98K
classified5009.95K0
Reference runApple M2 · 8 cores · 8 GB
Output · uv run a-dlq-that-does-not-become.py
naive      DLQ  3033  (good records in it:  2983)   applied   6967
classified DLQ    50  (good records in it:     0)   applied   9950
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

The naive policy dead-lettered 3.03K records, of which 2.98K were good: every record that arrived during the outage failed its three attempts and went to the with the malformed ones. Replaying the queue later would apply those 2.98K records out of order, behind records that arrived after them; the script counts them rather than replaying. The classified policy dead-lettered 50, all malformed, with 0 good records among them and 0 applied out of order, as expected.


Read it

A DLQ is for records that will never succeed, not for records that failed while something else was down. Classify the error before choosing the path: a malformed record goes to the DLQ at once, a timeout or an unavailable downstream is retried with backoff, and a failure rate above the threshold stops the consumer and pages someone.

Your DLQ grows by thousands during an incident and someone plans to "replay it later": that replay will apply those records out of order, after newer ones for the same keys. Treat a sudden jump in DLQ size as an outage signal, not as a queue to drain.