---
title: "Replay"
description: "Reading history again with two routes: a new group backfilled from the beginning, and an existing group rewound by a seek."
---

> Queen MQ documentation, for AI agents
> Complete self-contained summary of Queen MQ: https://queenmq.com/llms-brief.txt
> Fetch that first when the question is about the product rather than about this page.
> Index of all pages: https://queenmq.com/llms.txt

# Replay

Acknowledging a message does not delete it. Consumption is a cursor per consumer group and the
messages stay until retention removes them, so backfilling a new consumer, reprocessing after a
bug and auditing what was delivered all happen without asking the producer to send anything twice.

Two routes do it, and neither touches a message. `subscriptionMode` on the pop seeds a cursor that
does not exist yet, which is how a group created today reads events from last week.
`POST /api/v1/consumer-groups/<group>/queues/<queue>/seek` moves a cursor that already exists,
which is the only way to rewind a group: the mode is read once, at creation, and ignored on every
pop after it.

The seek body is either `{"toEnd": true}` or a timestamp, and a body carrying neither is a `400`.
Resolution is segment-granular rather than per-message, so the cursor lands just before the first
segment created at or after the timestamp. The seek also releases any live lease, so an in-flight
batch is abandoned rather than acknowledged.

```bash title="examples/tutorials/http/04-replay.sh"
#!/usr/bin/env bash
#
# Tutorial 4 of 4: replay, over plain HTTP.
#
# Acknowledging a message does not delete it. Consumption is a cursor per
# consumer group, and the messages stay until retention removes them, so a new
# group can read the whole history and an existing group can be moved back.
#
# This is the tutorial that shows what a cursor buys you: reprocessing after a
# bug, backfilling a new consumer, and auditing what was delivered, all without
# asking the producer to send anything twice. Two routes do all of it, and
# neither of them touches a message: subscriptionMode on the pop seeds a cursor
# that does not exist yet, and the seek route moves one that does.
#
# Run it:
#   QUEEN_URL=http://localhost:6632 bash 04-replay.sh

set -euo pipefail

QUEEN_URL="${QUEEN_URL:-http://localhost:6632}"
RUN="$(date +%s)-$$"
EVENTS="tut-http-replay-$RUN"
PARTITION=order-1

LIVE=tut-http-live
AUDIT=tut-http-audit

IDLE_MS=4000
EVENT_COUNT=4
EXPECTED=1,2,3,4

command -v jq >/dev/null 2>&1 || { echo "FAIL: jq is not installed"; exit 1; }

CHECKS=0
TMP="$(mktemp -d)"

cleanup() {
  local status=$?
  rm -rf "$TMP"
  if [ "$status" -ne 0 ]; then
    echo
    echo "FAIL: ${FAILURE:-a command exited with status $status}"
  fi
  exit "$status"
}
trap cleanup EXIT

fail() { FAILURE="$*"; exit 1; }

check() {
  [ "$1" = "$2" ] || fail "$3 (expected [$2], got [$1])"
  CHECKS=$((CHECKS + 1))
  echo "  ok: $3"
}

request() {
  local method="$1" path="$2" body="${3:-}"
  if [ -n "$body" ]; then
    STATUS="$(curl -sS -o "$TMP/body" -w '%{http_code}' \
      -X "$method" "$QUEEN_URL$path" \
      -H 'content-type: application/json' -d "$body")"
  else
    STATUS="$(curl -sS -o "$TMP/body" -w '%{http_code}' -X "$method" "$QUEEN_URL$path")"
  fi
}

# Commits the batch in $TMP/pop by acking its last message: the cursor moves to
# that offset and everything before it in the same partition is completed with
# it. Acking is what makes a group's progress durable; it is not what makes the
# messages go away, which is the whole point of this tutorial.
ack_batch() {
  local ack_body
  ack_body="$(jq -c --arg group "$1" '{
    transactionId: .messages[-1].transactionId,
    partitionId:   .messages[-1].partitionId,
    consumerGroup: $group,
    leaseId:       .leaseId,
    status:        "completed"
  }' "$TMP/pop")"
  request POST /api/v1/ack "$ack_body"
  [ "$STATUS" = 200 ] || fail "ack returned HTTP $STATUS"
  [ "$(jq -r '.[0].success' "$TMP/body")" = true ] \
    || fail "ack refused: $(jq -r '.[0].error' "$TMP/body")"
}

# drain <group> <expected> [subscriptionMode]
#
# Reads the lane through one group's cursor until it has seen <expected> events,
# acking as it goes, and leaves the sequence numbers it saw in $SEEN as a
# comma-separated list. A pop that long-polls its way to a 204 fails the run:
# with a known event count, silence means a message is missing.
drain() {
  local group="$1" expected="$2" mode="${3:-}" query seen
  query="consumerGroup=$group&batch=10"
  [ -z "$mode" ] || query="$query&subscriptionMode=$mode"

  : > "$TMP/seen"
  seen=0
  while [ "$seen" -lt "$expected" ]; do
    request GET "/api/v1/pop/queue/$EVENTS/partition/$PARTITION?$query&wait=true&timeout=$IDLE_MS"
    [ "$STATUS" != 204 ] || fail "$group went idle after $seen of $expected events"
    [ "$STATUS" = 200 ] || fail "pop for $group returned HTTP $STATUS"
    cp "$TMP/body" "$TMP/pop"
    jq -r '.messages[].data.seq' "$TMP/pop" >> "$TMP/seen"
    seen="$(wc -l < "$TMP/seen" | tr -d ' ')"
    ack_batch "$group"
  done
  SEEN="$(paste -sd, - < "$TMP/seen")"
}

echo "broker $QUEEN_URL"

# ---------------------------------------------------------------------------
# One call, four items. The items array is a batch: items in one request may
# even target different queues and partitions, and each (queue, partition) group
# becomes one segment and one commit. All four here share a partition, so they
# share a segment, and their order in the array is their order in the lane.
# ---------------------------------------------------------------------------
push_body="$(jq -n --arg queue "$EVENTS" --arg partition "$PARTITION" '{
  items: [
    {queue: $queue, partition: $partition, payload: {seq: 1, type: "created"}},
    {queue: $queue, partition: $partition, payload: {seq: 2, type: "updated"}},
    {queue: $queue, partition: $partition, payload: {seq: 3, type: "shipped"}},
    {queue: $queue, partition: $partition, payload: {seq: 4, type: "delivered"}}
  ]
}')"

echo
echo "POST $QUEEN_URL/api/v1/push"
echo "$push_body" | jq -c '.items[]'
request POST /api/v1/push "$push_body"
[ "$STATUS" = 201 ] || fail "push returned HTTP $STATUS"
echo "-> HTTP $STATUS"
jq -c '.[]' "$TMP/body"

# One element per item, in request order. Anything other than "queued" here
# would mean the message is not in PostgreSQL, whatever the 201 says.
[ "$(jq '[.[] | select(.status == "queued")] | length' "$TMP/body")" = "$EVENT_COUNT" ] \
  || fail "not every event was queued"

# ---------------------------------------------------------------------------
# The live consumer. It drains the lane and commits as it goes.
# ---------------------------------------------------------------------------
echo
echo "the live consumer"
drain "$LIVE" "$EVENT_COUNT" all
echo "  saw $SEEN"
check "$SEEN" "$EXPECTED" 'the live group read the lane in order'

# ---------------------------------------------------------------------------
# A second group, created now, after every message was already stored and
# acknowledged by someone else. subscriptionMode=all is what points its new
# cursor at the oldest retained message: the default for a new group is the
# tail, so without it this group would sit idle waiting for a fifth event and
# the pop would 204 out.
#
# The mode applies when the cursor is created and never again, so it cannot
# rewind a group that already exists. That is what seek below is for.
# ---------------------------------------------------------------------------
echo
echo "a new group, backfilled from the beginning"
drain "$AUDIT" "$EVENT_COUNT" all
echo "  saw $SEEN"
check "$SEEN" "$EXPECTED" 'a new group replayed the whole history'

# ---------------------------------------------------------------------------
# Nothing was re-pushed and nothing was copied: both groups read the same stored
# messages through their own cursors.
#
# Now rewind an existing group. The queue-scoped seek moves every partition of
# the queue for that group. The body takes either {"toEnd": true} or a
# timestamp; a body with neither is a 400.
#
# An hour ago is before anything in this run was pushed. Resolution is
# segment-granular, not per-message: the cursor lands just before the first
# segment created at or after the timestamp, and a timestamp older than what is
# retained lands on the oldest retained segment. The seek also releases any live
# lease, so an in-flight batch is abandoned rather than acknowledged.
#
# date is the one part of this script that is not the same on every machine:
# BSD date (macOS) spells it -v-1H, GNU date spells it -d '1 hour ago'.
# ---------------------------------------------------------------------------
echo
echo "rewinding an existing group"
HOUR_AGO="$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
  || date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)"

seek_body="$(jq -n --arg timestamp "$HOUR_AGO" '{timestamp: $timestamp}')"
echo "POST $QUEEN_URL/api/v1/consumer-groups/$LIVE/queues/$EVENTS/seek"
echo "$seek_body" | jq .
request POST "/api/v1/consumer-groups/$LIVE/queues/$EVENTS/seek" "$seek_body"
echo "-> HTTP $STATUS"
jq . "$TMP/body"

# A seek that matched no partition is a 404 with success:false, so this one is
# worth reading rather than assuming.
[ "$STATUS" = 200 ] || fail "seek returned HTTP $STATUS"
[ "$(jq -r .success "$TMP/body")" = true ] \
  || fail "seek failed: $(jq -r '.error // "no reason given"' "$TMP/body")"

# No subscriptionMode this time: the group's cursor already exists, so the
# parameter would be ignored. The seek is what moved it.
drain "$LIVE" "$EVENT_COUNT"
echo "  saw $SEEN"
check "$SEEN" "$EXPECTED" 'the rewound group read the same events again, in the same order'

# ---------------------------------------------------------------------------
# Replay is per group. The audit group was not moved, so it stays where it was
# and sees nothing new: a single-attempt pop (wait off) answers 204 immediately.
# ---------------------------------------------------------------------------
request GET "/api/v1/pop/queue/$EVENTS/partition/$PARTITION?consumerGroup=$AUDIT&batch=10"
check "$STATUS" 204 'rewinding one group left the other where it was'

request DELETE "/api/v1/resources/queues/$EVENTS"

echo
echo "PASS: $CHECKS checks"
```

## Run it

Against a broker from [the quickstart](/start/quickstart), with `curl` and `jq` on the path:

```bash
QUEEN_URL=http://localhost:6632 bash examples/tutorials/http/04-replay.sh
```

The script checks its own outcome and exits non-zero if a check fails. Every tutorial in this
section runs in the repository's own suite: `examples/tutorials/run.sh http`.

Source: https://queenmq.com/use/http-client/replay/index.mdx
