SQLStreams

the messaging platform that is just Postgres

You last visited on 9999-99-99 Show what's new since then

Message Lifecycle

Edit this page
Posted: 2026-09-09 · Report this thread
brandon Site Admin brandon profile Posts: 677

Consumer groups track progress with range leases and a cursor. A message that succeeds on its first delivery normally needs no exception row. Retries, requested delays, and messages waiting for a key get their own rows in the stream’s exception_queue.

TriggerStateNext action
A handler returns a retryable errorreadywait until can_run_after, then claim again
A handler returns sqlstreams.Delay(d)readywait for the requested time; count a delay
A key is busy or an ordered predecessor is unresolveddeferredwait until the key and ordering checks permit a claim
An eligible exception row is claimedinflightrun the handler under a lease
That handler succeedsrow deletedno further delivery for this group
A retryable error exhausts the budget, or the error is permanentdeadretain for inspection until message retention removes it
An exception row belongs to a compacted message that is no longer the headsupersededno further delivery of that version

An expired inflight lease permits another claim while retry budget remains. A repeated crash that exhausts the budget is marked dead. Handler Outcomes explains how return values, attempts, and delays determine that budget.

One group, four messages

The apply-balance group (id 7) reads ledger.adjustments (stream id 1) with ordered delivery for acct-42:

  1. Message 101 runs and returns an error. SQLStreams records a ready row with a future can_run_after.
  2. Messages 102–104 cannot pass 101. They are recorded deferred without running their handlers.
  3. The retry of 101 succeeds, so its exception row is deleted. The next eligible claim can run 102, then 103 and 104 in order.
  4. If 101 becomes dead instead, it also releases the messages behind it. The failure remains queryable, but it does not stop the cursor.

This state belongs to group 7. Another group reading message 101 has its own outcome and progress. Ordering & Concurrency explains the checks behind the waiting; Ordered Delivery shows the group declaration.

Retries and backoff

Set retry policy on the consumer group. Produced messages may request values within the group’s configured bounds:

orders := client.Stream[OrderPlaced]("orders.placed")
scoring := orders.Consumer("fraud-scoring")
consumer, err := scoring.Register(ctx, &sqlstreams.ConsumerConfig{
	Message: &sqlstreams.MessageOptions{
		Timeout: 30 * time.Second,
		Retry:   &sqlstreams.RetryPolicy{MaxRetries: 5, BaseDelay: 2 * time.Second},
	},
})
if err != nil {
	return err
}

A fresh retryable failure uses ExceptionInitialBackoff (default 5s). Subsequent failures use the retry curve: BaseDelay × Exponent^n, capped at MaxDelay, where n is the failed retry count minus one (zero for the first failed retry). Defaults are exponent 2, cap 5 minutes, and 3 retries. Requested delays do not increase the failure count. See Message Options for the fields and Handler Outcomes for the counters.

Backoff is stored in can_run_after. No process needs to keep a timer alive; the next eligible poll can claim the row. A due timestamp does not promise an immediate start, because polling and key ordering still apply.

Leases and crash recovery

A lease is an expiring claim recorded in a row. The handler runs after the claim transaction commits, without holding its database lock.

LeaseWhat survives a crashRecovery
Rangethe claimed range of message idsa later claim reclaims the expired range before taking fresh work
Exception rowone claimed retry or deferred deliveryanother claim can take the row after expiry

A range reclaimed too many times (MaxRangeReclaims, default 3) is quarantined into individual exception rows. That lets the remaining messages progress independently of a message that repeatedly crashes the process.

Successful handlers in an unresolved range can run again after reclaim. A timed-out handler that ignores cancellation can also keep running while a retry starts. Use idempotent handlers and pass their context to blocking calls. Consumer Timeouts explains cancellation and abandonment; Consumer shows how queue time affects the lease budget.

Inspect a failed message

Resolve the stream id before using its table suffix. For orders.placed:

SELECT id FROM sqlstreams.stream_config WHERE name = 'orders.placed';

If the result is 1, inspect failures for fraud-scoring:

SELECT
    d.message_id,
    m.payload,
    d.attempts,
    d.last_error,
    d.updated_at
FROM sqlstreams.exception_queue_1 d
JOIN sqlstreams.message_log_1 m ON m.id = d.message_id
JOIN sqlstreams.consumer_group_config g ON g.id = d.consumer_group_id
WHERE g.name = 'fraud-scoring' AND d.status = 'dead'
ORDER BY d.updated_at DESC;

delivery_log_1 holds the per-attempt audit trail. By default, DeliveryLogMode records outcomes other than success; all includes successes and off disables the audit trail. Exception state still exists when the audit trail is off.

Dead rows remain until retention removes their source message. A group’s cursor can pass dead messages, so cursor lag alone cannot establish that handlers succeeded. See progress and failures for the signals to inspect together.