Message Lifecycle
Edit this pageConsumer 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.
| Trigger | State | Next action |
|---|---|---|
| A handler returns a retryable error | ready | wait until can_run_after, then claim again |
A handler returns sqlstreams.Delay(d) | ready | wait for the requested time; count a delay |
| A key is busy or an ordered predecessor is unresolved | deferred | wait until the key and ordering checks permit a claim |
| An eligible exception row is claimed | inflight | run the handler under a lease |
| That handler succeeds | row deleted | no further delivery for this group |
| A retryable error exhausts the budget, or the error is permanent | dead | retain for inspection until message retention removes it |
| An exception row belongs to a compacted message that is no longer the head | superseded | no 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:
- Message 101 runs and returns an error. SQLStreams records a
readyrow with a futurecan_run_after. - Messages 102–104 cannot pass 101. They are recorded
deferredwithout running their handlers. - The retry of 101 succeeds, so its exception row is deleted. The next eligible claim can run 102, then 103 and 104 in order.
- If 101 becomes
deadinstead, 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.
| Lease | What survives a crash | Recovery |
|---|---|---|
| Range | the claimed range of message ids | a later claim reclaims the expired range before taking fresh work |
| Exception row | one claimed retry or deferred delivery | another 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.