---
title: "Transactional ack and push"
description: "One body, two operations, one PostgreSQL commit: the acknowledgement of the input and the push of the output."
---

> 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

# Transactional ack and push

The previous tutorial handed work between queues in two requests. In that order a crash between
them duplicates work, in the other order it loses it, and two HTTP calls can never be one commit.
`POST /api/v1/transaction` takes an `operations` array instead, so the ack of the input and the
push of the output become one PostgreSQL transaction.

Two fields on the ack operation carry it. `consumerGroup` has to be explicit, because it defaults
to `__QUEUE_MODE__` and the transaction would then commit a cursor this consumer never read from,
leaving the message to be redelivered forever. `leaseId` is what the broker checks, and an expired
lease rolls the whole transaction back. The SDK builders send the lease in a top-level
`requiredLeases` array; this route accepts that only as a fallback hint, so from curl put it on
the operation.

The commit is atomic and the round trip is not, which is why the pushed `transactionId` is derived
from the order rather than minted: a retry after a lost response either commits once or comes back
a duplicate. A rollback arrives as `200` with `success: false` and the reason at the top level.

```bash title="examples/tutorials/http/03-transaction-ack-push.sh"
#!/usr/bin/env bash
#
# Tutorial 3 of 4: acknowledge and push in one transaction, over plain HTTP.
#
# Tutorial 2 handed work from one queue to the next in two steps: push the
# derived message, then acknowledge the source. Between those two requests a
# crash duplicates work, and in the other order it loses work. Two HTTP calls
# can never be one commit.
#
# POST /api/v1/transaction closes that window: the acknowledgement of the input
# and the push of the output travel in one body and become one PostgreSQL
# transaction. Both land or neither does.
#
# Run it:
#   QUEEN_URL=http://localhost:6632 bash 03-transaction-ack-push.sh

set -euo pipefail

QUEEN_URL="${QUEEN_URL:-http://localhost:6632}"
RUN="$(date +%s)-$$"
ORDERS="tut-http-tx-orders-$RUN"
INVOICES="tut-http-tx-invoices-$RUN"
GROUP=tut-http-invoicing

IDLE_MS=5000

# orderId customer total
INPUT='A-1 acme 120.5
B-1 globex 88.75
C-1 initech 310.0'
ORDER_COUNT=3

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
}

echo "broker $QUEEN_URL"

echo
echo "pushing $ORDER_COUNT orders"
while read -r id customer total; do
  body="$(jq -n --arg queue "$ORDERS" --arg partition "$customer" \
    --arg id "$id" --argjson total "$total" \
    '{items: [{queue: $queue, partition: $partition,
               payload: {orderId: $id, customer: $partition, total: $total}}]}')"
  request POST /api/v1/push "$body"
  [ "$STATUS" = 201 ] || fail "push of $id returned HTTP $STATUS"
  [ "$(jq -r '.[0].status' "$TMP/body")" = queued ] || fail "push of $id was not queued"
done <<EOF
$INPUT
EOF

# ---------------------------------------------------------------------------
# Invoicing.
#
# Nothing here acks on its own: autoAck defaults to false on the pop route, and
# leaving it false is what makes this tutorial possible. The acknowledgement is
# not a separate call any more, it is an operation inside the transaction below.
# (autoAck=true would commit the cursor inside the pop's own transaction, before
# the response is even written: at-most-once, and nothing left to ack.)
# ---------------------------------------------------------------------------
echo
echo "invoicing"
INVOICED=0
FIRST=yes

while [ "$INVOICED" -lt "$ORDER_COUNT" ]; do
  request GET "/api/v1/pop/queue/$ORDERS?consumerGroup=$GROUP&subscriptionMode=all&batch=10&wait=true&timeout=$IDLE_MS"
  [ "$STATUS" != 204 ] || fail "invoicing went idle after $INVOICED of $ORDER_COUNT orders"
  [ "$STATUS" = 200 ] || fail "pop returned HTTP $STATUS"
  cp "$TMP/body" "$TMP/pop"

  LEASE="$(jq -r .leaseId "$TMP/pop")"

  # Everything the transaction needs about a message: its address (transactionId
  # plus its own partitionId) and the payload fields the next stage wants.
  jq -r '.messages[] | [.transactionId, .partitionId, .data.orderId,
                        .data.customer, (.data.total | tostring)] | @tsv' \
    "$TMP/pop" > "$TMP/batch"

  while IFS=$'\t' read -r txn partition_id id customer total; do
    # -----------------------------------------------------------------------
    # One body, two operations, one commit.
    #
    # The ack operation carries consumerGroup EXPLICITLY. Leave it out and it
    # defaults to __QUEUE_MODE__, so the transaction would commit a cursor this
    # consumer never read from and the order would be redelivered forever. This
    # is the single easiest thing to get wrong on this route.
    #
    # The popped leaseId goes ON THE ACK OPERATION. That is the field the broker
    # checks: a lease that has expired, or that belongs to another worker, is
    # refused with "invalid or expired lease; transaction rolled back", and
    # neither the ack nor the push lands. The SDK builders send the lease in a
    # top-level "requiredLeases" array instead; this route accepts that too, but
    # only as a hint it falls back to when it cannot resolve the live lease on
    # its own, so a wrong value there is silently ignored and the transaction
    # commits anyway. From curl, put it on the operation.
    #
    # The pushed transactionId is derived from the order, not random. The commit
    # is atomic but the round trip is not: if the response is lost you cannot
    # know whether it committed, and a retry with a deterministic id either
    # commits once or is rejected as a duplicate. Both end states are the same
    # one. With a random id, a retry writes the invoice twice.
    # -----------------------------------------------------------------------
    tx_body="$(jq -n \
      --arg txn "$txn" --arg partitionId "$partition_id" --arg group "$GROUP" \
      --arg lease "$LEASE" --arg queue "$INVOICES" --arg partition "$customer" \
      --arg id "$id" --argjson total "$total" \
      '{
        operations: [
          { type: "ack",
            transactionId: $txn,
            partitionId:   $partitionId,
            consumerGroup: $group,
            leaseId:       $lease,
            status:        "completed" },
          { type: "push",
            items: [{ queue: $queue,
                      partition: $partition,
                      transactionId: ("INV-" + $id),
                      payload: { invoiceId: ("INV-" + $id),
                                 orderId: $id,
                                 amount: $total } }] }
        ]
      }')"

    if [ "$FIRST" = yes ]; then
      echo
      echo "POST $QUEEN_URL/api/v1/transaction"
      echo "$tx_body" | jq .
    fi

    request POST /api/v1/transaction "$tx_body"

    if [ "$FIRST" = yes ]; then
      echo "-> HTTP $STATUS"
      jq . "$TMP/body"
      echo
      FIRST=no
    fi

    # A rollback is a 200 with success:false and the reason at the top level, so
    # the status code is not the answer here either. Check the transaction, not
    # just the absence of a connection error.
    [ "$STATUS" = 200 ] || fail "transaction returned HTTP $STATUS"
    [ "$(jq -r .success "$TMP/body")" = true ] \
      || fail "transaction rejected: $(jq -r '.error // "no reason given"' "$TMP/body")"

    INVOICED=$((INVOICED + 1))
    echo "  $id -> INV-$id"
  done < "$TMP/batch"
done

check "$INVOICED" "$ORDER_COUNT" 'every order was invoiced once'

# The commit fails if the lease has expired, which is what stops a slow consumer
# from acking work the broker has already handed to someone else. Nothing to
# assert here: the check above is that assertion, since a rejected commit would
# have failed the run.

# ---------------------------------------------------------------------------
# The invoices went to one partition per customer, and a pop claims a single
# partition unless you say otherwise: partitions=10 lets this one call claim up
# to ten of them, with batch as the total budget shared across all of them, not
# a per-partition limit. Without it this pop would return one customer's invoice
# and the check below would fail on a queue that is perfectly fine.
# ---------------------------------------------------------------------------
echo
echo "checking the output queue"
echo "GET /api/v1/pop/queue/$INVOICES?batch=10&partitions=10&wait=true&timeout=$IDLE_MS"
request GET "/api/v1/pop/queue/$INVOICES?batch=10&partitions=10&wait=true&timeout=$IDLE_MS"
[ "$STATUS" != 204 ] || fail 'no invoices were delivered'
[ "$STATUS" = 200 ] || fail "invoice pop returned HTTP $STATUS"
cp "$TMP/body" "$TMP/pop"
echo "-> HTTP 200, $(jq -r .partitionsClaimed "$TMP/pop") partitions claimed"

check "$(jq '.messages | length' "$TMP/pop")" "$ORDER_COUNT" "$ORDER_COUNT invoices exist"
check "$(jq -r '[.messages[].data.orderId] | sort | join(",")' "$TMP/pop")" \
  'A-1,B-1,C-1' 'each invoice matches an order, none duplicated'

# And the input queue is committed for this group: the acks were part of the
# same transactions that produced those invoices, so the two states cannot
# disagree. wait is left off, so this is a single attempt: 204 means the cursor
# is at the end.
request GET "/api/v1/pop/queue/$ORDERS?consumerGroup=$GROUP&batch=10"
check "$STATUS" 204 'the source queue is committed for this group'

request DELETE "/api/v1/resources/queues/$ORDERS"
request DELETE "/api/v1/resources/queues/$INVOICES"

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/03-transaction-ack-push.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`.

Next: [Replay](/use/http-client/replay).

Source: https://queenmq.com/use/http-client/transaction-ack-push/index.mdx
