Apache ActiveMQ Artemis and Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

Apache ActiveMQ Artemis — the next-generation broker that replaces ActiveMQ Classic with a journal-based persistence engine, native AMQP support, and built-in duplicate detection — introduces three billing failure modes that are entirely distinct from those in the Classic broker. The most dangerous of the three exploits a feature Artemis advertises as a safety tool: _AMQ_DUPL_ID duplicate detection prevents re-enqueuing at the broker level but does nothing to prevent a consumer from processing and calling stripe.charges.create() twice when the consumer dies after Stripe responds but before the ACK is sent. The second exploits _AMQ_GROUP_ID consumer pinning, which leads developers to maintain in-memory dedup state that vanishes when the pinned consumer is killed. The third mirrors ActiveMQ Classic’s DLQ retry problem in an Artemis-specific form: the dead-letter address retryMessage() operation re-enqueues the failed billing message with a fresh broker-assigned JMSMessageID, breaking any key derived from the original ID.

This post covers those three failure modes with Java (JMS API) code examples, content-hash idempotency keys that exclude every piece of Artemis routing and delivery metadata, pre-flight PostgreSQL checks with ON CONFLICT DO NOTHING, per-billing-period vault keys via a spend-cap proxy, and write-before-acknowledge ordering — the two-layer governance pattern that closes all three failure modes without changing Artemis broker configuration.

Failure mode 1: _AMQ_DUPL_ID duplicate detection fires at message enqueue time on the broker, not at consumer delivery time — developer who sets it as a billing idempotency guarantee calls stripe.charges.create() twice when the consumer crashes between the Stripe response and the consumer ACK

Apache ActiveMQ Artemis ships with a broker-level duplicate message detection mechanism. When a producer sets the special property _AMQ_DUPL_ID on a message and sends it to a Artemis address, the broker checks an in-memory (and optionally journal-persisted) duplicate ID cache. If the cache contains the same value, the broker silently drops the message and returns success to the producer — no exception, no error, just a quiet discard. The cache size is configurable via <id-cache-size> in broker.xml (default: 2,000,000 entries) and can be persisted to the journal via <persist-id-cache>true</persist-id-cache> so it survives broker restarts.

This is a legitimate and useful feature for exactly one scenario: preventing a producer from accidentally enqueuing the same message twice due to a send retry after a network timeout. The producer sends a billing event, receives no acknowledgment (network partition, broker restart), and retries the send. Without _AMQ_DUPL_ID, the broker would accept both sends and the message would be queued twice. With _AMQ_DUPL_ID, the second send is silently dropped.

The failure scenario: the billing team reads the Artemis documentation on duplicate detection, understands it as a dedup guarantee, and concludes that setting _AMQ_DUPL_ID = customer_id + ":" + billing_period on each billing message means Artemis will prevent the same customer from being billed twice in the same period. This conclusion is wrong. The _AMQ_DUPL_ID check fires at enqueue time from the producer. Once the message is in the queue, normal at-least-once delivery semantics apply: if the consumer fails to acknowledge the message — because the consumer JVM is killed, the consumer throws an uncaught exception, or the consumer calls consumer.rollback() on a transacted session — the broker redelivers the message to the next available consumer. The _AMQ_DUPL_ID check is not run on redelivery.

The failure timeline: consumer receives billing message for cust_123:Q3-2026. Consumer calls stripe.charges.create() and receives ch_A. Before calling message.acknowledge(), the consumer’s secondary database write fails with a connection timeout. The consumer throws an exception in onMessage(). The Artemis session is not acknowledged; the delivery count increments; the broker redelivers the message. The second consumer that receives the message has no knowledge of ch_A. There is no _AMQ_DUPL_ID check at this stage. The consumer calls stripe.charges.create() again and receives ch_B.

// Java — Artemis JMS producer with _AMQ_DUPL_ID (producer-side dedup only)
import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory;
import javax.jms.*;

public class BillingEventProducer {

    public void sendBillingEvent(String customerId, String billingPeriod, int amountCents)
            throws JMSException {

        try (Connection conn = factory.createConnection();
             Session session = conn.createSession(false, Session.AUTO_ACKNOWLEDGE)) {

            TextMessage message = session.createTextMessage(buildJson(customerId, billingPeriod, amountCents));
            message.setStringProperty("customer_id", customerId);
            message.setStringProperty("billing_period", billingPeriod);
            message.setIntProperty("amount_cents", amountCents);

            // _AMQ_DUPL_ID prevents the broker from accepting this same string twice
            // from the PRODUCER within the id-cache-size window.
            // This does NOT prevent consumer redelivery.
            message.setStringProperty("_AMQ_DUPL_ID", customerId + ":" + billingPeriod);

            producer.send(message);
        }
    }
}

// Consumer — unsafe because it trusts _AMQ_DUPL_ID as an end-to-end idempotency guarantee
public class BillingConsumer implements MessageListener {

    @Override
    public void onMessage(Message message) {
        try {
            String customerId = message.getStringProperty("customer_id");
            String billingPeriod = message.getStringProperty("billing_period");
            int amountCents = message.getIntProperty("amount_cents");

            // Developer assumption: "_AMQ_DUPL_ID ensures this won't be processed twice."
            // Actual behavior: _AMQ_DUPL_ID check happened at producer-send time, not here.
            // This consumer can be called multiple times for the same billing message
            // if a previous consumer died after stripe.charges.create() but before acknowledge().

            Map<String, Object> params = new HashMap<>();
            params.put("amount", amountCents);
            params.put("currency", "usd");
            params.put("customer", message.getStringProperty("stripe_customer_id"));
            // No Stripe idempotency key — developer relied on _AMQ_DUPL_ID
            // Result: each redelivery creates a new Stripe charge

            Charge charge = Charge.create(params);

            // If this line throws, the consumer won't acknowledge.
            // Artemis redelivers. _AMQ_DUPL_ID does NOT fire on redelivery.
            writeAuditLog(charge.getId(), customerId, billingPeriod);
            message.acknowledge();

        } catch (Exception e) {
            // Not acknowledging causes redelivery — next consumer calls Stripe again
            log.error("Billing failed, message will be redelivered", e);
        }
    }
}

The _AMQ_DUPL_ID documentation makes the scope of the feature explicit: it is described as “duplicate message detection” from the broker’s perspective, meaning duplicate messages arriving at the broker from producers. The word “consumer” does not appear in that section of the documentation. But developers reading “duplicate detection” in the context of billing naturally interpret it as covering the full delivery lifecycle.

A second variant: a developer sets _AMQ_DUPL_ID correctly AND adds a Stripe idempotency key derived from the _AMQ_DUPL_ID value. This seems correct — the idempotency key is stable across producer retries. But if the consumer derives the key from the _AMQ_DUPL_ID property value as read from the delivered message, and the redelivered message has a different _AMQ_DUPL_ID value (because the producer set different _AMQ_DUPL_ID values for the original send and the retry send using a timestamp or UUID suffix), the key changes on redelivery. The _AMQ_DUPL_ID value used at the consumer must be derived independently from stable business fields, not inherited from whatever the producer set on the message property.

Failure mode 2: _AMQ_GROUP_ID consumer pinning leads developers to trust in-memory dedup state that is reset when the pinned consumer is killed — Artemis re-pins the group to a new consumer with an empty in-memory set; redelivered billing messages call stripe.charges.create() for already-charged customers

Artemis message groups (_AMQ_GROUP_ID message property, or the JMS standard JMSXGroupID) cause the broker to pin all messages in the same group to a single consumer instance for the lifetime of that consumer. All messages where _AMQ_GROUP_ID = "customer:cust_123" will be delivered to the same consumer — not to any other consumer in the consumer pool — as long as that consumer remains connected and has available capacity. This is an ordering guarantee: all messages for a given group arrive in order at a single consumer.

Developers building billing pipelines use this feature to partition work by customer: all billing events for cust_123 go to consumer-2, all events for cust_456 go to consumer-5, and so on. The natural architectural conclusion is: “since all events for a customer go to the same consumer, I can use a consumer-local HashSet to track which customers I’ve billed this period.” Consumer-local in-memory state is fast, avoids database round-trips, and — as long as the consumer is alive — correctly prevents duplicate billing within a single consumer’s lifetime.

The failure: the pinned consumer is killed. Causes in production: JVM OOM kill (large prefetch buffer accumulates messages during a Stripe API slowdown; heap fills); SIGKILL from the deployment system during a rolling restart; an uncaught exception from an unrelated message in the same consumer that exits the JVM; max-delivery-attempts triggered by a separate billing message that exhausted retries and closed the session. In all cases, Artemis detects the consumer is gone and re-pins each group that consumer was handling to the next available consumer in the pool. The new consumer starts with an empty HashSet. All messages that were in-flight on the killed consumer — including those whose stripe.charges.create() completed successfully but whose message.acknowledge() was never sent — are redelivered to the new consumer. The new consumer’s in-memory check finds nothing in its empty set and calls stripe.charges.create() for every redelivered message. Each call within Stripe’s 24-hour idempotency window returns ch_A safely. Each call after the window creates ch_B.

// Java — Artemis JMS consumer with message group pinning and in-memory dedup (unsafe)
import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory;
import javax.jms.*;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class BillingConsumer implements MessageListener {

    // UNSAFE: in-memory set. Persists for the lifetime of this JVM only.
    // When this consumer is killed, re-pinned groups start fresh on a new consumer
    // with an empty billedThisPeriod set.
    private final Set<String> billedThisPeriod = ConcurrentHashMap.newKeySet();

    @Override
    public void onMessage(Message message) {
        try {
            String customerId = message.getStringProperty("customer_id");
            String billingPeriod = message.getStringProperty("billing_period");

            // _AMQ_GROUP_ID was set by the producer as "customer:" + customerId.
            // Developer reasoning: "This consumer handles all messages for this customer.
            // I can trust my local billedThisPeriod set — no other consumer sees this customer."
            // That reasoning is correct while this consumer is alive.
            // It breaks the moment this consumer dies and the group re-pins to consumer-2.
            String groupId = message.getStringProperty("JMSXGroupID"); // for logging only

            String dedupeKey = customerId + ":" + billingPeriod;

            if (billedThisPeriod.contains(dedupeKey)) {
                // UNSAFE: billedThisPeriod is empty on the new consumer after re-pinning.
                // This check never fires for redelivered messages on a new consumer.
                log.info("Already billed {} for {}, skipping", customerId, billingPeriod);
                message.acknowledge();
                return;
            }

            int amountCents = message.getIntProperty("amount_cents");

            // Content-hash idempotency key is missing here — developer trusted billedThisPeriod
            Map<String, Object> params = new HashMap<>();
            params.put("amount", amountCents);
            params.put("currency", "usd");
            params.put("customer", message.getStringProperty("stripe_customer_id"));

            Charge charge = Charge.create(params);

            billedThisPeriod.add(dedupeKey);
            message.acknowledge();

        } catch (Exception e) {
            log.error("Billing failed for group {}", message.getStringProperty("JMSXGroupID"), e);
            // Not acknowledging — message redelivered. billedThisPeriod.add() was never called.
            // On redelivery (possibly on a new consumer after this one crashes),
            // stripe.charges.create() runs again.
        }
    }
}

The Artemis documentation describes the group re-pinning behavior precisely: “If a consumer is closed, the group will be rebound to another consumer on the same queue.” This sentence describes correct broker behavior and is not a bug. The bug is in the assumption that in-memory state on the consumer is durable enough to serve as a billing dedup gate.

A subtler variant: the developer does use a database pre-flight check, but it is consumer-local — a SQLite database file on the consumer’s local filesystem. A new consumer spun up on a different host (common in containerized deployments) has a different SQLite file, or no file at all. The pre-flight check finds no rows and calls Stripe. The correct architecture is a shared, external PostgreSQL database with a unique constraint on (customer_id, billing_period) that all consumers — on all hosts, past and future — query before calling Stripe.

The _AMQ_GROUP_ID feature is useful for the ordering guarantee it provides. It is not a dedup mechanism and was never designed to be one. The delivery guarantee for message groups is “messages in a group are delivered in order to the same consumer,” not “messages in a group are delivered exactly once.”

Failure mode 3: Artemis dead-letter address retryMessage() re-enqueues the billing message as a new message with a new broker-assigned JMSMessageID — the original ID is preserved only in _AMQ_ORIG_MESSAGE_ID; any key derived from message.getJMSMessageID() on the retried message produces a different Stripe key and Stripe creates ch_B

When a billing message exceeds Artemis’s max-delivery-attempts (configured per-address in broker.xml, default 10), the broker moves it to the configured dead-letter address. In a typical Artemis setup this is an address named DLA with a corresponding queue DLA, configured via:

<!-- broker.xml address-setting for the billing queue -->
<address-setting match="billing.events">
    <dead-letter-address>DLA</dead-letter-address>
    <max-delivery-attempts>10</max-delivery-attempts>
    <redelivery-delay>5000</redelivery-delay>
    <redelivery-multiplier>2.0</redelivery-multiplier>
    <max-redelivery-delay>60000</max-redelivery-delay>
</address-setting>

When an administrator reviews the DLA and retries a billing message using the Artemis management console (the Hawtio-based web UI at /console), a JMX call, or the Artemis CLI:

# Artemis CLI — retry one dead-lettered message by its internal message ID
./artemis queue retryMessage \
    --name DLA \
    --url tcp://localhost:61616 \
    --message-id 12345678

# Or retry all messages in the DLA
./artemis queue retryAllMessages \
    --name DLA \
    --url tcp://localhost:61616

Artemis re-enqueues the dead-lettered message to its original address (billing.events) as a new message with a new broker-assigned internal message ID and a new JMSMessageID string. The original JMSMessageID is moved to a special message property: _AMQ_ORIG_MESSAGE_ID. The original destination address is stored in _AMQ_ORIG_ADDRESS. The delivery count resets to 1. From the consumer’s perspective, the retried message looks like a fresh first delivery with no history.

The failure scenario: a billing message for cust_789:Q3-2026:$99.00 was delivered 10 times. On delivery attempt 7, stripe.charges.create() succeeded and returned ch_A. The consumer then called a secondary ledger API that was down; the API call threw an exception; the consumer did not acknowledge the message; JMSXDeliveryCount incremented to 8. Attempts 8, 9, and 10 also failed the ledger call after successfully hitting Stripe (Stripe returned ch_A on all attempts within the 24-hour window). On attempt 11, the broker moved the message to the DLA. Three days later, an administrator sees the DLA message, fixes the ledger API outage, and runs retryMessage(). The message arrives at the consumer as a new first-delivery message with JMSXDeliveryCount = 1 and a new JMSMessageID = "ID:artemis-broker-01:1234567890-abc". The original JMSMessageID is in _AMQ_ORIG_MESSAGE_ID. The developer’s idempotency key was sha256("artemis-billing:" + jmsMessageId). The new key is completely different. The 24-hour Stripe idempotency window closed two days ago. Stripe creates ch_B and charges the customer $99.00 a second time.

// Java — Artemis JMS consumer with JMSMessageID-derived idempotency key (unsafe on DL retry)
import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory;
import javax.jms.*;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import java.security.MessageDigest;
import java.util.HexFormat;

public class BillingConsumer implements MessageListener {

    @Override
    public void onMessage(Message message) {
        try {
            String customerId = message.getStringProperty("customer_id");
            String billingPeriod = message.getStringProperty("billing_period");
            int amountCents = message.getIntProperty("amount_cents");

            // Get the active JMSMessageID — this is a NEW ID on DL retry.
            // The original ID is in _AMQ_ORIG_MESSAGE_ID, but most developers don't check it.
            String jmsMessageId = message.getJMSMessageID();

            // Check if this message was retried from the DLA.
            // Most developers don't add this branch — they never expected the ID to change.
            String origMessageId = message.getStringProperty("_AMQ_ORIG_MESSAGE_ID");
            if (origMessageId != null) {
                // This message was retried from the dead-letter address.
                // origMessageId was used to compute the idempotency key on original attempts.
                // jmsMessageId is different — using it produces a NEW Stripe key.
                log.warn("DLA retry detected. Original JMSMessageID: {}, New: {}", origMessageId, jmsMessageId);
                // Developer who DOES check this property still needs to handle the 24h window:
                // if DL retry happens > 24h after original charge, even the original key
                // won't return ch_A — Stripe's idempotency cache has expired.
            }

            // UNSAFE: idempotency key derived from JMSMessageID
            // On DL retry, jmsMessageId is the new broker-assigned ID,
            // NOT the original delivery's JMSMessageID.
            // Stripe sees a new unique key and creates ch_B.
            String idempotencyKey = sha256("artemis-billing:" + jmsMessageId + ":" + customerId);

            Map<String, Object> params = new HashMap<>();
            params.put("amount", amountCents);
            params.put("currency", "usd");
            params.put("customer", message.getStringProperty("stripe_customer_id"));

            // RequestOptions sets Stripe-Idempotency-Key header
            Charge charge = Charge.create(params, RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());

            writeLedger(charge.getId(), customerId, billingPeriod);
            message.acknowledge();

        } catch (Exception e) {
            log.error("Billing failed", e);
            // Not acknowledging — delivery count increments, redelivery scheduled
        }
    }

    private static String sha256(String input) {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256").digest(input.getBytes());
            return HexFormat.of().formatHex(digest).substring(0, 32);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

The _AMQ_ORIG_MESSAGE_ID property is only present on messages that were retried from a dead-letter address. It is absent on first deliveries and standard redeliveries. A consumer can check for its presence and fall back to the original ID when building an idempotency key. But this only helps within Stripe’s 24-hour idempotency window. A DL retry that happens more than 24 hours after the original charge — which is common in production, where dead-lettered messages may wait for hours or days for an operator to investigate and retry — creates a new charge even if the consumer uses the correct original JMSMessageID. The pre-flight PostgreSQL check is the only mechanism that closes this failure regardless of how much time has passed.

The safe pattern: content-hash idempotency key + pre-flight PostgreSQL + vault key spend cap

All three failure modes are closed by the same two-layer pattern. The layers are complementary: the content-hash idempotency key handles failures within Stripe’s 24-hour idempotency window; the pre-flight PostgreSQL check handles failures beyond the window and concurrent-consumer races; the vault key spend cap provides a hard backstop for the edge cases where both layers have a coverage gap.

// Java — Artemis JMS consumer: safe pattern
import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory;
import javax.jms.*;
import com.stripe.Stripe;
import com.stripe.model.Charge;
import com.stripe.net.RequestOptions;
import java.security.MessageDigest;
import java.sql.*;
import java.util.HexFormat;
import java.util.HashMap;
import java.util.Map;

public class SafeBillingConsumer implements MessageListener {

    private final DataSource dataSource; // shared PostgreSQL pool

    @Override
    public void onMessage(Message message) {
        try {
            String customerId   = message.getStringProperty("customer_id");
            String billingPeriod = message.getStringProperty("billing_period");
            int    amountCents  = message.getIntProperty("amount_cents");

            // Content-hash idempotency key derived exclusively from business fields.
            // Does NOT include: JMSMessageID (new on DLA retry), JMSXDeliveryCount
            // (increments on redelivery), JMSXGroupID / _AMQ_GROUP_ID (routing metadata),
            // _AMQ_DUPL_ID (producer-side token), _AMQ_SCHED_DELIVERY (delivery timestamp),
            // _AMQ_ORIG_MESSAGE_ID (absent on first delivery), JMSTimestamp (broker-set),
            // JMSExpiration (changes on DLA re-enqueue).
            String idempotencyKey = contentHashKey(customerId, billingPeriod);

            // Pre-flight INSERT: claims the billing slot before calling Stripe.
            // ON CONFLICT DO NOTHING is a no-op if a concurrent consumer or a
            // previous delivery already inserted the row.
            // RETURNING id is null if ON CONFLICT fired — indicates already-billed.
            boolean claimed = claimBillingSlot(customerId, billingPeriod);
            if (!claimed) {
                // Another consumer (or a previous delivery) already billed this period.
                log.info("Already billed {}/{}, skipping", customerId, billingPeriod);
                message.acknowledge();
                return;
            }

            // stripe.charges.create() with content-hash key.
            // Within 24h: Stripe returns the same ch_A for duplicate keys.
            // Beyond 24h: pre-flight check already returned false above.
            Map<String, Object> params = new HashMap<>();
            params.put("amount", amountCents);
            params.put("currency", "usd");
            params.put("customer", message.getStringProperty("stripe_customer_id"));

            Charge charge = Charge.create(params, RequestOptions.builder()
                    .setIdempotencyKey(idempotencyKey)
                    .build());

            // Write Stripe charge ID into billing_records so orphaned rows are detectable.
            updateBillingRecord(customerId, billingPeriod, charge.getId());

            // Acknowledge only after Stripe call and DB write succeed.
            message.acknowledge();

        } catch (stripe.exception.ApiConnectionException e) {
            // Network error — Stripe may or may not have processed the charge.
            // Do NOT acknowledge. Pre-flight row already exists.
            // Redelivery will find the pre-flight row, skip Stripe, and re-attempt the ledger write.
            // Reconciliation job detects rows with no stripe_charge_id.
            log.warn("Stripe network error, message will be redelivered for reconciliation", e);
        } catch (Exception e) {
            log.error("Billing failed", e);
            // Not acknowledging — message redelivered. Pre-flight row blocks double-charge.
        }
    }

    private boolean claimBillingSlot(String customerId, String billingPeriod) throws SQLException {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(
                "INSERT INTO billing_records (customer_id, billing_period, status, inserted_at) " +
                "VALUES (?, ?, 'pending', NOW()) " +
                "ON CONFLICT (customer_id, billing_period) DO NOTHING")) {
            ps.setString(1, customerId);
            ps.setString(2, billingPeriod);
            int rows = ps.executeUpdate();
            return rows == 1; // 1 = claimed; 0 = ON CONFLICT fired (already exists)
        }
    }

    private void updateBillingRecord(String customerId, String billingPeriod, String chargeId)
            throws SQLException {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(
                "UPDATE billing_records SET stripe_charge_id = ?, status = 'billed', billed_at = NOW() " +
                "WHERE customer_id = ? AND billing_period = ?")) {
            ps.setString(1, chargeId);
            ps.setString(2, customerId);
            ps.setString(3, billingPeriod);
            ps.executeUpdate();
        }
    }

    private static String contentHashKey(String customerId, String billingPeriod) {
        String raw = customerId + ":" + billingPeriod + ":artemis-billing";
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256").digest(raw.getBytes());
            return HexFormat.of().formatHex(digest).substring(0, 32);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

The vault key (described in detail here) adds a third layer: a per-billing-period Stripe restricted key with a spend cap of expected_total × 1.10. If both the content-hash key and the pre-flight check fail to prevent a duplicate — for example, the billing_records table is temporarily unavailable and the consumer proceeds without the pre-flight check, or a DL retry fires 25 hours after the original charge with neither key hitting the idempotency cache nor the pre-flight row being found — the vault key absorbs the second charge at the proxy layer and returns a policy-violation error before the Stripe API is reached.

Protection comparison: five patterns × three Artemis failure modes

Protection pattern FM1: _AMQ_DUPL_ID false security FM2: _AMQ_GROUP_ID in-memory dedup lost FM3: DLA retryMessage() new JMSMessageID
No protection (no key, no pre-flight) ch_B on any consumer restart ch_B on consumer kill + group re-pin ch_B on DLA retry
_AMQ_DUPL_ID only (producer-side) ch_B — dedup fires at enqueue, not redelivery ch_B — in-memory dedup still cleared on kill ch_B — DLA retry assigns new JMSMessageID
JMSMessageID-derived key + no pre-flight Safe within 24 h; ch_B after window Safe within 24 h; ch_B after >24 h outage ch_B (new JMSMessageID on DLA retry)
Content-hash key only (no pre-flight) Safe within 24 h; ch_B after idempotency window closes Safe within 24 h; ch_B after multi-day outage Safe within 24 h; ch_B after 24 h DLA delay
Full pattern: content-hash + pre-flight + vault cap Safe always Safe always Safe always

Gap analysis: four additional Artemis failure modes

1. Artemis consumer-window-size (default 1 MB) buffers messages in the consumer before delivery to onMessage(); a killed consumer has 100+ messages buffered and none acknowledged

Artemis sends messages to the consumer’s local buffer up to consumer-window-size (configurable per-consumer, default 1 MB) before any are processed. When a consumer connection drops — JVM killed, network partition, idle timeout — all messages in the local buffer are returned to the queue as unacknowledged. The delivery count increments for each. A consumer pool where each consumer has 1 MB of buffered messages can have hundreds of redeliveries triggered by a single consumer kill. The content-hash idempotency key and pre-flight check handle all concurrent redeliveries correctly: ON CONFLICT DO NOTHING ensures only one consumer wins the billing slot per (customer_id, billing_period). Setting consumer-window-size=0 (or a smaller value like 1 message) reduces the number of redeliveries but does not eliminate them — the pre-flight check remains the authoritative guard.

2. Artemis scheduled delivery (_AMQ_SCHED_DELIVERY) re-enqueues a billing message after a scheduled delay; if included in the idempotency key, the delivery timestamp changes on each scheduling round

Artemis supports scheduled message delivery via the _AMQ_SCHED_DELIVERY message property, which specifies the earliest delivery timestamp in milliseconds since epoch. A developer who implements retry-with-backoff by re-enqueuing the billing message with a future _AMQ_SCHED_DELIVERY timestamp — rather than relying on Artemis’s built-in redelivery-delay — gets a new message with a new _AMQ_SCHED_DELIVERY value on each retry. If the idempotency key includes _AMQ_SCHED_DELIVERY (“to distinguish first-attempt billing from retry-attempt billing”), the key changes with each scheduling round. The content-hash key that excludes all scheduling metadata is stable across all manually scheduled retries.

3. Artemis management API sendMessageToAddress() used for DLA triage copies the message without the original delivery history; a billing team tool that uses this API to “forward” DLA messages to a review queue introduces a third message copy

Artemis’s management API exposes sendMessageToAddress(), which allows an operator to send an arbitrary message to any address. Billing teams sometimes build DLA triage tools using this API: read the DLA message properties, create a new message with the same body and properties, and send it to a billing.review queue for manual processing. If the triage tool also re-sends the message back to the original billing.events queue for automatic retry (in addition to the operator clicking “Retry” in the console), two copies of the billing message are in the billing.events queue simultaneously. The pre-flight ON CONFLICT DO NOTHING handles the race between two consumers processing these two copies: one insert wins, one is silently no-oped. Without the pre-flight check, both consumers call Stripe.

4. Artemis cluster topology with symmetric-cluster address routing can deliver the same message to consumers on different nodes during a node failure and rejoin

In an Artemis symmetric cluster (all nodes subscribe to all addresses), a message sent to a clustered address can be routed to any node for consumption. During a node failure and rejoin, messages in-flight on the failing node are redistributed to other nodes. If the billing consumer pool spans multiple cluster nodes and a billing message was half-processed on the failing node (Stripe call succeeded, acknowledge not sent), the message is redistributed to a consumer on a live node. That consumer has no knowledge of the Stripe call on the failed node. The content-hash idempotency key and pre-flight check are both node-agnostic (the pre-flight check queries a shared external PostgreSQL, not a node-local store) and handle this redistribution correctly.

FAQ

Is Artemis’s _AMQ_DUPL_ID useful at all for billing systems?

Yes, but for a narrower use case than most developers assume. Set _AMQ_DUPL_ID on billing messages in the producer to prevent the producer from enqueueing the same billing event twice due to a retry loop or network timeout. This is a legitimate and valuable guarantee. The failure is in conflating “the broker won’t accept this message twice from the producer” (what _AMQ_DUPL_ID guarantees) with “the consumer won’t process this message twice” (what it does not guarantee). Use _AMQ_DUPL_ID as a producer-side safety net and a content-hash key plus pre-flight check as the consumer-side safety net. They are complementary, not interchangeable.

What happens to the _AMQ_GROUP_ID binding when the pinned consumer is killed?

Artemis detects the consumer disconnection (via heartbeat or TCP close), marks all unacknowledged messages from that consumer as pending redelivery, and re-pins each group that consumer was handling to the next available consumer on the same queue. The re-pinning is immediate once the broker detects the disconnection — there is no grace period. The new consumer receives the redelivered messages as regular deliveries with incremented JMSXDeliveryCount. From the new consumer’s perspective, the messages look like ordinary deliveries. The only special marker is JMSXDeliveryCount > 1 and, if the address is configured with persist-delivery-count-before-delivery=true, the delivery count survives a broker restart as well.

Can I use _AMQ_ORIG_MESSAGE_ID in the Stripe idempotency key to handle DLA retry correctly?

Partially. Using _AMQ_ORIG_MESSAGE_ID as the key on DLA-retried messages produces the same key that was used during the original delivery attempts. This is safe within Stripe’s 24-hour idempotency window: Stripe returns ch_A without creating a new charge. Beyond 24 hours, Stripe’s idempotency cache has expired and a new charge is created regardless of the key used. DLA retries often happen more than 24 hours after the original failure (operators are not always available immediately; DLA queues can accumulate). The pre-flight check is the correct mechanism for the >24-hour case: it checks a durable PostgreSQL row that never expires. Use a content-hash key (stable across all delivery scenarios including DLA retry without any special-casing for _AMQ_ORIG_MESSAGE_ID) plus a pre-flight check. This closes all cases without conditional key logic.

Does setting max-delivery-attempts=1 (no redelivery) prevent duplicate charges from failure modes 1 and 2?

Setting max-delivery-attempts=1 means any unacknowledged message immediately goes to the dead-letter address. This eliminates redelivery-based duplicates from failure modes 1 and 2. However, failure mode 3 (DLA retryMessage() creating a new JMSMessageID) still applies and is in fact the only delivery path for billing messages when max-delivery-attempts=1. Additionally, max-delivery-attempts=1 means that a transient Stripe network error (consumer throws ApiConnectionException and does not acknowledge) immediately dead-letters the billing message rather than retrying. The billing message must then be manually retried from the DLA by an operator. During that manual retry, failure mode 3 applies. The pre-flight check is necessary regardless of max-delivery-attempts setting.

Does the pre-flight check with ON CONFLICT DO NOTHING handle the case where two consumers in the same group race on a redelivered message?

Yes. With _AMQ_GROUP_ID, Artemis normally pins a group to a single consumer, so two consumers should not simultaneously receive the same group message. However, during the brief window when the original pinned consumer has disconnected but the group has not yet been re-pinned, a race is theoretically possible if the disconnection detection and re-pinning happen concurrently with a message timeout and redistribution. In practice, Artemis serializes group assignment, so this race is extremely rare. Regardless, ON CONFLICT DO NOTHING on a unique constraint on (customer_id, billing_period) handles it correctly: the first consumer to execute the INSERT wins the billing slot; subsequent inserts for the same pair are silently no-oped and the consumers skip the Stripe call. PostgreSQL’s constraint enforcement is atomic; no additional locking is required.

Put the brakes on your agent’s Stripe key

Keybrake proxies your agent’s Stripe calls with per-period spend caps, endpoint allowlists, and a full audit log. If an Artemis consumer fires twice — DLA retry, group re-pin, or consumer-window-size redelivery — the vault key cap absorbs the second charge before it reaches Stripe.