Alerting on Dead-Letter Queue Growth

Alert on the rate at which a dead-letter queue grows and on the appearance of new failure classes — never on its absolute depth. A queue holding 340 entries is meaningless without knowing whether that is Tuesday’s normal or the result of a source changing its projection at 02:00. Export three gauges from the queue (open depth by class, arrival rate, and the age of the oldest unresolved entry), alert when arrivals exceed the drain rate for long enough to matter, and page immediately on a class that has never been seen before.

When to Use This Pattern

  • A dead-letter queue already exists and is written to by the pipeline. Alerting on an empty design is a no-op.
  • The pipeline runs unattended — nightly, hourly — so nobody would otherwise notice a steady 3% failure rate until a downstream consumer complains.
  • Failures are heterogeneous: several sources, several failure classes, so a single count hides the interesting change.
  • You have Prometheus or an equivalent that can scrape a gauge and evaluate a rule over a window. A daily digest email is a weaker version of the same idea and is much better than nothing.

The reason depth alone fails as a signal is worth being concrete about. Consider a tiling pipeline that dead-letters roughly forty tiles a day from a flaky upstream and drains them each night. Its depth oscillates between zero and about sixty, so any threshold below sixty pages every afternoon and any threshold above it misses a genuine regression that adds thirty a day for a fortnight — by the time depth crosses sixty the pipeline has been quietly broken for two weeks. The information is entirely in how the number moves, and none of it is in where the number is.

Three gauges, three questionsOpen depth by class answers what is broken. Arrivals in the last hour answers whether it is getting worse. The age of the oldest entry answers whether anything is draining at all.open depth by classwhat is broken?groups the backlognever alerted on directlyarrivals, last houris it getting worse?drives the growth ruleand the new-class ruleoldest entry ageis anything draining?the only rule that catchesa dead drain flow
Each gauge is one query and answers one question. Trying to serve all three questions from a single depth number is what makes queue alerting feel useless.

Complete Working Example

The exporter is a small periodic job. It runs three queries and sets three gauge families; everything else — thresholds, windows, routing — lives in the alert rules, where it can be changed without a deploy.

from __future__ import annotations

import time

import psycopg
from prometheus_client import Gauge, start_http_server

DLQ_DEPTH = Gauge(
    "geotask_dlq_open_entries",
    "Unresolved dead-letter entries",
    ["failure_class", "pipeline"],
)
DLQ_OLDEST = Gauge(
    "geotask_dlq_oldest_seconds",
    "Age of the oldest unresolved entry, in seconds",
    ["pipeline"],
)
DLQ_ARRIVALS = Gauge(
    "geotask_dlq_arrivals_1h",
    "Entries written in the last hour",
    ["failure_class", "pipeline"],
)

DEPTH_SQL = """
SELECT failure_class, count(*)
FROM   geometry_dlq
WHERE  resolved_at IS NULL
GROUP  BY failure_class
"""

OLDEST_SQL = """
SELECT COALESCE(EXTRACT(EPOCH FROM now() - min(failed_at)), 0)
FROM   geometry_dlq
WHERE  resolved_at IS NULL
"""

ARRIVALS_SQL = """
SELECT failure_class, count(*)
FROM   geometry_dlq
WHERE  failed_at > now() - interval '1 hour'
GROUP  BY failure_class
"""


def collect(conn: psycopg.Connection, pipeline: str = "parcels") -> None:
    """Refresh every gauge. Cheap enough to run once a minute on an indexed table."""
    with conn.cursor() as cur:
        # Clear stale label sets first: a class that drops to zero must report zero,
        # not keep its last value forever. A missing series looks like "no data",
        # which most alert routes treat as OK — the wrong default here.
        DLQ_DEPTH.clear()
        DLQ_ARRIVALS.clear()

        cur.execute(DEPTH_SQL)
        for failure_class, count in cur.fetchall():
            DLQ_DEPTH.labels(failure_class=failure_class, pipeline=pipeline).set(count)

        cur.execute(OLDEST_SQL)
        DLQ_OLDEST.labels(pipeline=pipeline).set(cur.fetchone()[0])

        cur.execute(ARRIVALS_SQL)
        for failure_class, count in cur.fetchall():
            DLQ_ARRIVALS.labels(failure_class=failure_class, pipeline=pipeline).set(count)


def main(dsn: str, interval_seconds: int = 60) -> None:
    start_http_server(9109)
    with psycopg.connect(dsn) as conn:
        while True:
            collect(conn)
            conn.rollback()      # keep the snapshot fresh; this is a read-only loop
            time.sleep(interval_seconds)

The rules are where the judgement lives. These three cover the cases that matter, and deliberately leave out “depth above N”:

groups:
  - name: geotask-dlq
    rules:
      # 1. Arrivals outpacing drains for an hour. This is the workhorse alert:
      #    it fires on a pipeline that is failing steadily, which is invisible
      #    to run-success dashboards.
      - alert: DeadLetterQueueGrowing
        expr: |
          sum by (pipeline) (delta(geotask_dlq_open_entries[1h])) > 50
        for: 15m
        labels: { severity: ticket }
        annotations:
          summary: "{{ $labels.pipeline }} dead-letter queue grew by {{ $value }} in an hour"

      # 2. A class nobody has seen before. Almost always a real change upstream:
      #    a new CRS, a new geometry type, a schema edit.
      - alert: NewDeadLetterClass
        expr: |
          sum by (pipeline, failure_class) (geotask_dlq_arrivals_1h) > 0
          unless
          sum by (pipeline, failure_class) (geotask_dlq_arrivals_1h offset 7d) > 0
        for: 10m
        labels: { severity: page }
        annotations:
          summary: "new failure class {{ $labels.failure_class }} on {{ $labels.pipeline }}"

      # 3. Nothing is draining. Depth may be flat and still be wrong if the
      #    oldest entry keeps ageing — that means the drain flow is broken.
      - alert: DeadLetterQueueStale
        expr: geotask_dlq_oldest_seconds > 7 * 24 * 3600
        for: 1h
        labels: { severity: ticket }
        annotations:
          summary: "oldest unresolved dead letter on {{ $labels.pipeline }} is over a week old"
The shape that matters is the trend, not the levelA healthy queue rises during the day and returns near zero after each nightly drain. An unhealthy queue rises every day and never returns, while the run-success rate stays at one hundred per cent throughout.6003000MonThuSunnever drains — alert on this slopehealthy sawtooth — never alertRun success rate over the same week: 100% on both lines. That is the whole problem.
A depth threshold would fire on the green line every afternoon and stay silent on the red line for four days. The derivative is the signal.

Two details in those rules repay a second look. delta(...[1h]) > 50 measures the change in depth, which nets arrivals against drains — a pipeline adding 200 entries an hour while the drain removes 190 is not an emergency, and this expression says so, where a raw arrival-rate alert would not. And the new-class rule uses unless … offset 7d rather than a threshold, because the interesting property of a new failure class is not how many entries it has but that it exists at all: one entry with failure_class="unknown" from a source that has run cleanly for a year is worth more attention than four hundred timeouts.

What none of these rules do is tell you the pipeline is fine. A queue can stay flat and empty because the pipeline stopped running altogether, which is a different alert on a different signal — run freshness — and the two are complementary. It is worth writing that pairing down somewhere visible, because a team that has just built queue alerting tends to assume it now covers silence too.

Parameter & Option Reference

Setting Default Spatial notes
scrape interval 60 s The queries are indexed aggregates; a minute is cheap. Do not scrape a queue table every second.
DeadLetterQueueGrowing threshold +50/hour Set it from a fortnight of history, not from taste. A pipeline that normally adds 5/hour should alert at 30, not 50.
for: on growth 15 m Rides out the burst a single failed source causes during one flow run.
NewDeadLetterClass lookback 7 d Long enough to cover a weekly source. Shorten to 24 h for pipelines whose sources are all daily.
DeadLetterQueueStale 7 d Catches a drain flow that silently stopped — depth flat, ages growing.
.clear() before set required Without it, a class that falls to zero keeps reporting its last value and the alert never resolves.
oldest-entry gauge per pipeline Not per class: the question is whether anything is stuck, and a per-class version is noisy.

Verification & Testing

Alert rules are code and deserve tests. promtool evaluates them against a synthetic series, which is far more reliable than waiting for a real incident.

# dlq_alerts_test.yaml — run with: promtool test rules dlq_alerts_test.yaml
rule_files: [dlq_alerts.yaml]
evaluation_interval: 1m
tests:
  - interval: 1m
    input_series:
      # A steady climb of 2 entries a minute for two hours.
      - series: 'geotask_dlq_open_entries{pipeline="parcels",failure_class="crs_mismatch"}'
        values: '0+2x120'
    alert_rule_test:
      - eval_time: 90m
        alertname: DeadLetterQueueGrowing
        exp_alerts:
          - exp_labels: { severity: ticket, pipeline: parcels }

  - interval: 1m
    input_series:
      # A sawtooth: rises to 60, drained to 0, repeatedly. Must NOT alert.
      - series: 'geotask_dlq_open_entries{pipeline="parcels",failure_class="upstream_timeout"}'
        values: '0+1x60 0+1x60'
    alert_rule_test:
      - eval_time: 115m
        alertname: DeadLetterQueueGrowing
        exp_alerts: []

The second test is the important one. Most first attempts at this alert fire on the healthy sawtooth, and the fastest way to find that out is a test rather than a week of pages.

Which rule catches which failureThe growth rule catches a steadily failing pipeline. The new-class rule catches an upstream change. The staleness rule catches a broken drain flow.rulewhat it actually catchesresponsedelta(depth[1h]) > 50a pipeline failing steadilygreen dashboards, growing holeticketclass unseen in 7 dan upstream changenew CRS, new geometry typepageoldest > 7 dthe drain flow stoppeddepth flat, ages climbingticket
Only one of the three is worth waking someone for. A new failure class means the data changed shape, and every hour of delay is another hour of entries.

Common Pitfalls

  • Alerting on absolute depth. It fires every afternoon on a healthy queue and stays silent through four days of monotonic growth if the starting point was low. The derivative is what carries the information.
  • Forgetting to clear stale label sets. A failure_class that stops occurring keeps its last gauge value forever unless the exporter clears its labels, so a resolved problem alerts indefinitely and everyone learns to ignore the rule.
  • One alert for every class. Ten classes times three rules is thirty alerts, most of which are duplicates of the same incident. Aggregate with sum by (pipeline) for the growth rule and reserve per-class alerting for the new-class rule.
  • No alert on the drain itself. If the drain flow dies, depth stops falling but also stops rising sharply — it can look almost normal. The oldest-entry gauge is what makes that visible, and it is the rule people most often skip.
  • Scraping the queue table with an unindexed query. count(*) WHERE resolved_at IS NULL on a large table without the partial index is a sequential scan every minute. Index it, as in storing failed geometries in a PostGIS dead-letter queue.

Frequently Asked Questions

Should dead-letter growth page someone at night?

Rarely. Growth is a ticket: the failures are already captured, and drinking coffee before triaging them costs nothing. The exception is a pipeline feeding a live service, where a growing queue means the served data is going stale — there the alert is really about freshness, and belongs with your data-quality SLOs.

How do I choose the growth threshold?

Query the last fortnight: SELECT date_trunc('hour', failed_at), count(*) FROM geometry_dlq GROUP BY 1. Take the 95th percentile of the hourly counts and roughly double it. Thresholds picked this way survive; thresholds picked from a round number get silenced within a month.

Can I alert straight from SQL instead of Prometheus?

Yes, and for a small setup a scheduled query that posts to a chat channel is a perfectly good version of this. What you lose is the window functions — delta over 1h, offset 7d — which you would then have to hand-roll. If the pipeline already exports metrics, reuse that path; see Prometheus metrics for raster throughput.

What about queue depth per source rather than per class?

Useful, and cheap to add as a second label — but keep the cardinality in check. A source_uri label with 4 000 distinct values will hurt Prometheus; a source_group label with a dozen will not. The same discipline applies as in exporting per-tile metrics without cardinality blowups.

Dead-Letter Queues for Failed Geotasks