Spring Integration Solace Stripe Integration: How SolaceInboundChannelAdapter AcknowledgeMode.AUTO_ACK Fires on onReceive() Return Before ExecutorChannel Dispatches to the ServiceActivator Silently Losing the BillingRecord on DB Exception Enabling ch_B on Reconciliation Republish, How SolaceAcknowledgmentCallback.acknowledge(ACCEPT) Inside @Transactional Sends the JCSMP Settlement Unaffected by Spring Rollback Permanently Consuming the Message Without a BillingRecord, and How SolaceInboundChannelAdapter.stop() During Graceful Shutdown Closes the JCSMP FlowReceiver Without Draining In-Flight ServiceActivator Processing Generating UUID_B Committing ch_B
Spring Integration’s SolaceInboundChannelAdapter introduces three acknowledgement and lifecycle behaviors that differ structurally from both the raw JCSMP FlowReceiver and the Spring Cloud Stream Solace Binder: an AcknowledgeMode.AUTO_ACK setting whose ack timing depends on the output channel type in a way that surprises developers moving from DirectChannel to ExecutorChannel, a CLIENT_ACK mode whose SolaceAcknowledgmentCallback.acknowledge() method issues an immediate JCSMP network call rather than participating in Spring’s transaction lifecycle, and a stop() shutdown path that closes the JCSMP FlowReceiver without draining in-flight ServiceActivator work — each of which produces duplicate Stripe charges when the billing handler calls UUID.randomUUID() per invocation rather than a stable content-derived key.
This post covers all three failure modes end to end: the exact SolaceInboundChannelAdapter configuration that enables each one, the step-by-step timeline showing precisely when ch_A commits and when UUID_B is generated, the specific misconception each mode exploits about how Spring Integration Solace acknowledgement and shutdown work, and the idempotency fix that makes the billing ServiceActivator safe regardless of channel type, transactional rollback, or graceful shutdown timing. Code samples target solace-spring-integration 3.x, JCSMP 10.x, Spring Integration 6.x, Spring Boot 3.x, WireMock, Testcontainers with the Solace PubSub+ Docker image, and JUnit 5.
Background: how SolaceInboundChannelAdapter differs from the raw JCSMP subscriber and the SCS Solace Binder at the acknowledgement and lifecycle layers
To understand why these three failure modes are specific to Spring Integration Solace rather than to the raw com.solacesystems.jcsmp API or the SCS Solace Binder (covered earlier in this series), you need to understand three structural differences introduced by SolaceInboundChannelAdapter.
SolaceInboundChannelAdapter’s AcknowledgeMode.AUTO_ACK fires at the point where the message is dispatched to the Spring Integration output channel, not at the point where the downstream ServiceActivator completes processing. The raw JCSMP API in AcknowledgeMode.AUTO_ACK sends the acknowledgement when the ConsumerFlowProperties listener’s onReceive() callback returns. In SolaceInboundChannelAdapter, onReceive() calls outputChannel.send(siMessage) and the adapter’s AUTO_ACK logic fires after channel.send() returns. This distinction matters because DirectChannel.send() is synchronous — it blocks until the entire downstream handler chain returns — so AUTO_ACK fires after the ServiceActivator completes, giving the appearance of correct behavior. ExecutorChannel.send(), however, returns as soon as the message task is enqueued in the executor thread pool, before any downstream handler thread picks it up. When the team switches from DirectChannel to ExecutorChannel for throughput, AUTO_ACK silently shifts from “ack after processing” to “ack after dispatch”.
The SolaceAcknowledgmentCallback obtained from a Spring Integration Message header wraps a direct JCSMP IMessage.ackMessage() network call, not a Spring TransactionSynchronization resource. The SCS Solace Binder’s transactedSession=true mode uses a JCSMP JCSMPTransactedSession whose commit() can be ordered relative to the Spring transaction lifecycle — although it is still not an XA resource and commits sequentially rather than atomically, as the SCS Solace post in this series shows. SolaceInboundChannelAdapter in CLIENT_ACK mode does not use a transacted JCSMP session. The adapter injects a SolaceAcknowledgmentCallback into the Spring Integration Message headers via the SolaceAcknowledgmentUtils helper. When application code calls acknowledge(Status.ACCEPT), the callback immediately calls IMessage.ackMessage() on the underlying JCSMP message, which sends a direct acknowledgement control message to the Solace broker over the JCSMP session socket. This call is not deferred, not transactional, and cannot be reversed by any Spring transaction operation. JmsTransactionManager works because the JMS session is a ConnectionFactory-managed resource that Spring’s transaction manager knows how to coordinate; JCSMP has no equivalent Spring transaction resource adapter in the solace-spring-integration library.
The SmartLifecycle.stop() implementation on SolaceInboundChannelAdapter closes the JCSMP FlowReceiver at the adapter layer, not at the processing layer. A JMS MessageConsumer closed via consumer.close() while an onMessage() callback is active will block the close() call until the current onMessage() invocation returns — this is mandated by the JMS 2.0 specification (Section 8.7). JCSMP has no equivalent guarantee. When FlowReceiver.close() is called, the JCSMP session returns all messages that were received by the flow but not yet acknowledged (in CLIENT_ACK mode) to the Solace broker immediately, regardless of what the application is doing with those messages. For a DirectChannel configuration where the adapter’s JCSMP callback thread is the ServiceActivator’s processing thread, stop() is typically called from a separate shutdown thread — meaning the FlowReceiver.close() races with the in-progress handleMessage() call. For a QueueChannel configuration, the adapter thread and the ServiceActivator thread are always separate, and the race is guaranteed to occur whenever shutdown happens while a message is in the QueueChannel buffer or in a PollingConsumer dispatch.
With these three structural differences understood, each failure mode follows directly from a specific configuration choice.
Failure mode 1: AcknowledgeMode.AUTO_ACK + ExecutorChannel — JCSMP ack fires on channel dispatch before ServiceActivator runs; DB exception silently loses BillingRecord; reconciliation republish → UUID_B → ch_B
The setup
A team building a billing consumer on Solace PubSub+ starts with a simple SolaceInboundChannelAdapter wired to a DirectChannel. The configuration works correctly in load testing: messages are processed one at a time on the JCSMP subscriber thread and the AUTO_ACK fires after each ServiceActivator invocation completes. Throughput is limited to a single message at a time per consumer thread, so the team switches the output channel to an ExecutorChannel with a thread pool of 10 workers to process billing events concurrently. The ack mode stays at the default AcknowledgeMode.AUTO_ACK.
The billing handler generates its Stripe idempotency key with UUID.randomUUID() at the start of each handleMessage() invocation:
@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
String idempotencyKey = UUID.randomUUID().toString(); // new key every call
RequestOptions options = RequestOptions.builder()
.setIdempotencyKey(idempotencyKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getCustomerId())
.build();
Charge charge = Charge.create(params, options);
billingRecordRepo.save(new BillingRecord(event, idempotencyKey, charge.getId()));
}
The Spring Integration configuration changes the output channel from DirectChannel to ExecutorChannel:
@Bean
public SolaceInboundChannelAdapter billingAdapter(
@Qualifier("billingChannel") MessageChannel outputChannel,
JCSMPSession jcsmpSession,
SolaceConsumerDestination destination) throws JCSMPException {
SolaceInboundChannelAdapter adapter =
new SolaceInboundChannelAdapter(jcsmpSession, destination, "billing-events-queue");
adapter.setOutputChannel(outputChannel);
// AcknowledgeMode not explicitly set → defaults to AUTO_ACK
return adapter;
}
@Bean
public MessageChannel billingChannel() {
// Changed from new DirectChannel() for throughput
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(200);
executor.initialize();
return new ExecutorChannel(executor);
}
The Solace queue is configured with the default maxDeliveryCount=255 and a dead-message queue (DMQ) enabled. The reconciliation job runs every 72 hours and compares Stripe charges against BillingRecord rows, republishing any charge without a matching record.
The failure timeline
A billing event M1 arrives for customer cust_EU_042. A transient HikariCP connection pool exhaustion causes billingRecordRepo.save() to throw CannotGetJdbcConnectionException at t=520ms, after the Stripe API has already committed ch_A.
- t=0.000s — JCSMP delivers M1 to the
SolaceInboundChannelAdapter’s registered consumer flow callback (onReceive()) on the JCSMP subscriber thread. - t=0.001s — The adapter calls
billingChannel.send(siMessage). BecausebillingChannelis now anExecutorChannel,send()enqueues a task in the executor thread pool and returns immediately without waiting for the task to be executed. The JCSMP subscriber thread is now unblocked and continues. - t=0.002s — With
AcknowledgeMode.AUTO_ACK, the adapter’sonReceive()logic callsIMessage.ackMessage()on M1. JCSMP sends the acknowledgement to the Solace broker. M1 is permanently removed from the queue from the broker’s perspective. - t=0.003s — An executor thread picks up the task.
handleBillingEvent()begins.UUID.randomUUID()generatesUUID_A. The StripeCharge.create()API call begins. - t=0.500s — Stripe processes the charge and responds
200 OKwithcharge.id = ch_A. - t=0.520s —
billingRecordRepo.save(new BillingRecord(event, UUID_A, ch_A))throwsCannotGetJdbcConnectionException(HikariCP pool exhausted under load). - t=0.521s — The exception propagates out of
handleBillingEvent(). Spring Integration routes it to theerrorChannel. The error handler logs the exception and moves on. No retry is configured on the executor channel. - t=0.522s — From Solace’s perspective: M1 was acknowledged at t=0.002s. M1 is not redelivered. The failed processing is completely invisible to the broker.
- t=72h+ — The reconciliation job finds Stripe charge
ch_Awithout a matchingBillingRecord. It reconstructs the original billing event and publishes M2 (a new message with the same business payload) to the billing queue. - t=72h+0.500s —
handleBillingEvent(M2)runs.UUID.randomUUID()generatesUUID_B. Stripe’s 24-hour idempotency cache has expired. Stripe processes a new charge and returnsch_B.BillingRecord(UUID_B, ch_B)is saved.
The customer is charged twice: ch_A at t=0.500s and ch_B at t=72h+0.500s. Neither charge produced an error visible to the billing consumer. The system appears to have processed M1 correctly (ack sent, no exception logged at the adapter layer) and to have recovered M2 correctly (reconciliation found the orphaned charge and republished). The double-charge is not detectable until the customer disputes it or until a separate audit compares charge totals against subscription records.
Developer misconception
“
AcknowledgeMode.AUTO_ACKfires after myServiceActivatorhas finished processing the message — that’s what ‘AUTO’ means. I use anExecutorChannelfor throughput, but Spring Integration must track the in-flight executor task before firing the ack. It works like JMSAUTO_ACKNOWLEDGEwhich fires afterMessageListener.onMessage()returns, regardless of which thread runsonMessage().”
This misconception conflates when Spring Integration fires the ack (after channel.send() returns, which is after the adapter’s onReceive() hook runs) with when the downstream ServiceActivator completes (after the executor thread finishes handleMessage(), which is decoupled from the adapter thread). The JMS analogy breaks down because JMS AUTO_ACKNOWLEDGE fires after the MessageListener.onMessage() call returns — and since the JMS MessageConsumer delivers to onMessage() synchronously (on the JMS consumer thread), “after onMessage() returns” and “after processing is done” are the same. Spring Integration decouples the two: the adapter’s onReceive() calls channel.send(), and the ack fires when channel.send() returns, not when the downstream handler finishes. For DirectChannel, these happen to be the same event. For ExecutorChannel, QueueChannel, and PriorityChannel, they are not.
The fix
Two independent fixes, both required:
Fix 1: Use AcknowledgeMode.CLIENT_ACK and send the acknowledgement only after the ServiceActivator completes successfully. With ExecutorChannel, the ack must be called from the executor thread that runs the ServiceActivator, after the handler returns without exception:
@Bean
public SolaceInboundChannelAdapter billingAdapter(
@Qualifier("billingChannel") MessageChannel outputChannel,
JCSMPSession jcsmpSession,
SolaceConsumerDestination destination) throws JCSMPException {
SolaceInboundChannelAdapter adapter =
new SolaceInboundChannelAdapter(jcsmpSession, destination, "billing-events-queue");
adapter.setOutputChannel(outputChannel);
adapter.setAcknowledgeMode(SolaceAcknowledgeMode.CLIENT); // explicit CLIENT_ACK
return adapter;
}
@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
AcknowledgmentCallback ackCallback =
StaticMessageHeaderAccessor.getAcknowledgmentCallback(message);
String stableKey = "billing:" + sha256(
event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
);
if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
return; // already processed
}
try {
RequestOptions options = RequestOptions.builder()
.setIdempotencyKey(stableKey)
.build();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getCustomerId())
.build();
Charge charge = Charge.create(params, options);
billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
} catch (StripeException e) {
// Stripe failed — requeue for retry
ackCallback.acknowledge(AcknowledgmentCallback.Status.REQUEUE);
throw new MessageHandlingException(message, "Stripe API failure", e);
} catch (DataAccessException e) {
// DB failed after Stripe committed — stable key makes retry safe
ackCallback.acknowledge(AcknowledgmentCallback.Status.REQUEUE);
throw new MessageHandlingException(message, "DB failure after Stripe commit", e);
}
}
Fix 2: Use a stable content-derived idempotency key. Even with correct ack placement, the reconciliation job may still republish events under rare conditions (duplicate charge in Stripe from a different failure path, replay for audit purposes). A stable key makes every republish idempotent regardless of how much time has elapsed:
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
With both fixes applied: CLIENT_ACK from the executor thread ensures that a DB exception after a successful Stripe charge causes REQUEUE rather than silent loss; the Solace broker redelivers M1; the stable key causes Stripe to return ch_A from its idempotency cache (within 24 hours) or causes the existsByIdempotencyKey guard to short-circuit after the BillingRecord is eventually saved. The reconciliation path also becomes safe: any republish for any reason uses the same stable key.
Failure mode 2: SolaceAcknowledgmentCallback.acknowledge(ACCEPT) inside @Transactional — JCSMP settlement fires as immediate network call; @Transactional rollback leaves no BillingRecord; message permanently consumed; reconciliation republish → UUID_B → ch_B
The setup
A senior engineer on the platform team, with deep Spring JMS experience, is tasked with migrating the billing consumer from an ActiveMQ Artemis queue to Solace PubSub+. In the ActiveMQ version, the consumer uses DefaultMessageListenerContainer with sessionTransacted=true and JmsTransactionManager. The JMS session acknowledgement is tied to the Spring @Transactional boundary: if the DB write fails and the transaction rolls back, the JMS session also rolls back, and the message is redelivered. The engineer wants to preserve this guarantee on Solace and chooses AcknowledgeMode.CLIENT_ACK so that the billing service can control when to call acknowledge(ACCEPT).
Following the JMS pattern, the engineer calls acknowledge(ACCEPT) inside the @Transactional billing service method, after the Stripe charge succeeds and before the billingRecordRepo.save() call, reasoning that if the save throws and the transaction rolls back, the Solace ack will be rolled back too:
@Bean
public SolaceInboundChannelAdapter billingAdapter(
@Qualifier("billingChannel") MessageChannel outputChannel,
JCSMPSession jcsmpSession,
SolaceConsumerDestination destination) throws JCSMPException {
SolaceInboundChannelAdapter adapter =
new SolaceInboundChannelAdapter(jcsmpSession, destination, "billing-events-queue");
adapter.setOutputChannel(outputChannel);
adapter.setAcknowledgeMode(SolaceAcknowledgeMode.CLIENT);
return adapter;
}
@ServiceActivator(inputChannel = "billingChannel")
public void handleBillingEvent(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
AcknowledgmentCallback ackCallback =
StaticMessageHeaderAccessor.getAcknowledgmentCallback(message);
billingService.chargeAndRecord(event, ackCallback);
}
@Service
public class BillingService {
@Transactional
public void chargeAndRecord(BillingEvent event, AcknowledgmentCallback ackCallback) {
String idempotencyKey = UUID.randomUUID().toString();
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getCustomerId())
.build();
Charge charge = Charge.create(params,
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build());
// Engineer's intent: "ack inside @Transactional so rollback reverses the ack"
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT); // ← fires immediately
// Outbox insert: throws DataIntegrityViolationException on duplicate key
outboxRepository.insertBillingEvent(event.getId(), idempotencyKey, charge.getId());
billingRecordRepo.save(new BillingRecord(event, idempotencyKey, charge.getId()));
}
}
The failure timeline
Customer cust_EU_077 triggers a billing event. A duplicate outbox row from a prior failed attempt (stale DB state from a crash during a previous deployment) causes the insertBillingEvent call to throw DataIntegrityViolationException.
- t=0.000s — JCSMP delivers M1 to the adapter. The adapter dispatches M1 to
billingChannel(aDirectChannel).handleBillingEvent()runs on the JCSMP subscriber thread. - t=0.001s —
billingService.chargeAndRecord(event, ackCallback)is called. Spring opens a database transaction. - t=0.002s —
UUID.randomUUID()generatesUUID_A. The StripeCharge.create()call begins. - t=0.430s — Stripe responds
200 OK.charge.getId()returnsch_A. - t=0.431s —
ackCallback.acknowledge(Status.ACCEPT)is called inside the open@Transactionalmethod.SolaceAcknowledgmentCallbackcallsIMessage.ackMessage()on the underlying JCSMP message. JCSMP immediately sends anACKcontrol message to the Solace broker over the session socket. The broker marks M1 as permanently consumed and removes it from the queue. This network operation is not deferred, not queued as a Spring transactional resource, and cannot be reversed by any subsequent Spring operation. - t=0.432s —
outboxRepository.insertBillingEvent()is called. The outbox table has a unique constraint on(event_id, billing_period). A stale row from a prior crash attempt exists.DataIntegrityViolationExceptionis thrown. - t=0.483s — Spring’s
@Transactionalproxy catches the exception, rolls back the database transaction, and rethrows. ThebillingRecordRepo.save()was never reached. NoBillingRecordexists in the database. The Stripe chargech_Ais committed to the customer’s card. - t=0.484s — The exception propagates to Spring Integration’s
errorChannel. The error handler logsDataIntegrityViolationException. From Solace’s perspective: M1 was acknowledged at t=0.431s. M1 will not be redelivered. - t=72h+ — The reconciliation job finds
ch_Ain Stripe without a matchingBillingRecord. It publishes M2 to the billing queue. - t=72h+0.430s —
handleBillingEvent(M2)runs.UUID.randomUUID()generatesUUID_B. Stripe’s 24-hour idempotency cache has expired. Stripe createsch_B. The outbox stale row was cleaned up by then;BillingRecord(UUID_B, ch_B)is saved successfully.
The customer’s card now has two charges: ch_A (t=0.430s, Day 1) and ch_B (t=72h+0.430s, Day 4). The billing team notices the DataIntegrityViolationException in the Day 1 logs and investigates the outbox constraint issue. They clean up the stale row and mark the incident resolved. Three days later, a customer dispute reveals ch_B. The two issues appear unrelated in the logs because they are separated by 72 hours.
Developer misconception
“Calling
acknowledge(ACCEPT)inside a@Transactionalmethod is the same pattern asJmsTransactionManagerwheresession.commit()is deferred to the Spring transaction boundary. If the Spring transaction rolls back,session.commit()is never called, and the JMS message is not acknowledged. I assumedSolaceAcknowledgmentCallback.acknowledge()works the same way: it queues the acknowledgement to be sent when the transaction commits and reverses it when the transaction rolls back.”
JmsTransactionManager works because it registers the JMS Session as a ConnectionFactory-backed resource with Spring’s TransactionSynchronizationManager. The session is enlisted in the transaction at the start of the @Transactional method; session.commit() fires in TransactionSynchronization.afterCommit(); if the transaction rolls back, session.rollback() fires instead. SolaceAcknowledgmentCallback in solace-spring-integration 3.x does not implement or participate in TransactionSynchronizationManager. It holds a direct reference to the JCSMP IMessage object. Calling acknowledge(ACCEPT) calls IMessage.ackMessage() immediately. There is no Solace JTA XAResource in the solace-spring-integration library; the analogy to JmsTransactionManager does not hold.
The fix
Move acknowledge(ACCEPT) out of the @Transactional method body and into a TransactionSynchronization.afterCommit() hook. This ensures the Solace ack is sent only after the database transaction commits successfully. If the transaction rolls back, the ack is not sent, the message remains unacknowledged to Solace, and the broker redelivers M1 for retry:
@Service
public class BillingService {
@Transactional
public void chargeAndRecord(BillingEvent event, AcknowledgmentCallback ackCallback) {
String stableKey = "billing:" + sha256(
event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
);
if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
// Already processed — ack and return
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override public void afterCommit() {
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
}
});
return;
}
ChargeCreateParams params = ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getCustomerId())
.build();
Charge charge = Charge.create(params,
RequestOptions.builder().setIdempotencyKey(stableKey).build());
outboxRepository.insertBillingEvent(event.getId(), stableKey, charge.getId());
billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));
// Register ack to fire only after DB transaction commits
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
}
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
// Do not ack — Solace will redeliver M1 after ack timeout
// The stable key makes redelivery safe
}
}
});
}
}
Note that in AcknowledgeMode.CLIENT_ACK, if IMessage.ackMessage() is never called (i.e., when the transaction rolls back and no ack is sent), Solace redelivers the message after the queue’s configured redeliveryDelay (default: 0ms — immediate redelivery after disconnect or session timeout). The stable key makes the redelivery path safe: the redelivered message carries the same business payload and generates the same stable key; the existsByIdempotencyKey(stableKey) guard short-circuits if the DB write eventually succeeds; the Stripe call uses the same key and the idempotency cache returns ch_A within 24 hours. Beyond 24 hours, the guard prevents the Stripe call entirely.
Failure mode 3: SolaceInboundChannelAdapter.stop() + QueueChannel + CLIENT_ACK — FlowReceiver.close() releases unacknowledged messages to Solace; second pod processes redelivered M1; UUID_B → ch_B
The setup
A team uses SolaceInboundChannelAdapter with AcknowledgeMode.CLIENT_ACK and a QueueChannel for backpressure control. The QueueChannel buffers up to 100 messages in memory; a PollingConsumer with a fixed-rate poller dispatches messages from the queue to the billing ServiceActivator. The billing handler calls acknowledge(ACCEPT) after a successful Stripe charge and DB write, and acknowledge(REQUEUE) on failure. The team is running three replicas in Kubernetes with a terminationGracePeriodSeconds=30.
@Bean
public SolaceInboundChannelAdapter billingAdapter(
@Qualifier("billingBuffer") MessageChannel outputChannel,
JCSMPSession jcsmpSession,
SolaceConsumerDestination destination) throws JCSMPException {
SolaceInboundChannelAdapter adapter =
new SolaceInboundChannelAdapter(jcsmpSession, destination, "billing-events-queue");
adapter.setOutputChannel(outputChannel);
adapter.setAcknowledgeMode(SolaceAcknowledgeMode.CLIENT);
return adapter;
}
@Bean
public MessageChannel billingBuffer() {
return new QueueChannel(100); // in-memory buffer
}
@Bean
public IntegrationFlow billingPollingFlow(
@Qualifier("billingBuffer") MessageChannel billingBuffer) {
return IntegrationFlow
.from(billingBuffer, spec -> spec.poller(Pollers.fixedRate(50, TimeUnit.MILLISECONDS)))
.handle("billingHandler", "handleBillingEvent")
.get();
}
// In BillingHandler:
public void handleBillingEvent(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
AcknowledgmentCallback ackCallback =
StaticMessageHeaderAccessor.getAcknowledgmentCallback(message);
String idempotencyKey = UUID.randomUUID().toString(); // new key every call
try {
Charge charge = Charge.create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getCustomerId())
.build(),
RequestOptions.builder().setIdempotencyKey(idempotencyKey).build()
);
billingRecordRepo.save(new BillingRecord(event, idempotencyKey, charge.getId()));
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
} catch (Exception e) {
ackCallback.acknowledge(AcknowledgmentCallback.Status.REQUEUE);
throw new MessageHandlingException(message, "billing failed", e);
}
}
The failure timeline
Pod-A is processing M1 for customer cust_EU_099. The Stripe charge call is in-flight when Kubernetes sends SIGTERM to Pod-A for a rolling deployment update.
- t=0.000s — JCSMP delivers M1 to the
SolaceInboundChannelAdapteron Pod-A. The adapter places a Spring IntegrationMessagewrapping M1 (with theSolaceAcknowledgmentCallbackholding a reference to the JCSMPIMessage) into theQueueChannelbuffer. The adapter’s JCSMP subscriber thread returns from the placement call and is available for the next message. M1 is inCLIENT_ACK— Solace does not consider it acknowledged. - t=0.010s — The
PollingConsumerfires (50ms fixed-rate), retrieves M1 from theQueueChannel, and dispatches it tohandleBillingEvent()on the poller thread. - t=0.012s —
UUID.randomUUID()generatesUUID_A. The StripeCharge.create()API call begins. - t=0.600s — Kubernetes delivers SIGTERM to Pod-A. The JVM receives it. Spring Boot’s graceful shutdown hook fires:
SpringApplication.shutdownHook()triggers aContextClosedEvent. - t=0.601s — Spring calls
stop()on allSmartLifecyclebeans in reverse dependency order.SolaceInboundChannelAdapter.stop()is reached. The adapter callsFlowReceiver.close()on the JCSMP consumer flow. - t=0.602s — JCSMP
FlowReceiver.close()closes the consumer endpoint. Per JCSMPCLIENT_ACKsemantics: all messages received by this flow that have not been acknowledged viaIMessage.ackMessage()are immediately returned to the Solace queue. M1 was received by this flow at t=0.000s and has not been acknowledged. JCSMP internally marks M1 as unacknowledged and informs the Solace broker. Solace makes M1 available for redelivery. - t=0.603s — Pod-B’s
SolaceInboundChannelAdapter(same queue, non-exclusive queue binding) receives M1 from Solace. The adapter places it into Pod-B’sQueueChannel. - t=0.653s — Pod-B’s poller dispatches M1 to
handleBillingEvent().UUID.randomUUID()generatesUUID_B. A new Stripe charge call begins withidempotencyKey=UUID_B. - t=1.050s — Pod-A’s Stripe call (started at t=0.012s) returns
200 OKwithch_A.billingRecordRepo.save(new BillingRecord(event, UUID_A, ch_A))succeeds. The handler callsackCallback.acknowledge(Status.ACCEPT). TheSolaceAcknowledgmentCallbackcallsIMessage.ackMessage()on the JCSMP message. The JCSMP session is in the process of closing (theFlowReceiveris already closed). JCSMP throwsJCSMPSessionException: Flow is closed. The ack attempt is logged as an error and ignored. - t=1.200s — Pod-B’s Stripe call returns
200 OKwithch_B.BillingRecord(UUID_B, ch_B)is saved. Pod-B’s ack succeeds.
The customer’s card now has two charges: ch_A (Pod-A, committed at t=1.050s) and ch_B (Pod-B, committed at t=1.200s). The incident report shows a JCSMPSessionException on Pod-A at t=1.050s, which the team attributes to the expected behavior of the closing JCSMP session and marks as non-critical. The BillingRecord for ch_A is saved on Pod-A before the JVM exits. The BillingRecord for ch_B is saved on Pod-B. No deduplication check ran because UUID_A and UUID_B are different keys.
Developer misconception
“I use
CLIENT_ACKspecifically so the Solace adapter knows which messages are in-flight. Whenstop()is called during graceful shutdown, the adapter will not release M1 back to Solace until myServiceActivatorhas either acknowledged or rejected it — that’s the whole point ofCLIENT_ACK. It works like JMSMessageConsumer.close(), which per the JMS spec waits for any activeonMessage()invocation to complete before returning.”
The JMS specification (Section 8.7) requires that MessageConsumer.close() blocks until any concurrently executing MessageListener.onMessage() call completes. JCSMP has no equivalent contractual guarantee for FlowReceiver.close(). The JCSMP client library documentation states that messages received but not acknowledged in CLIENT_ACK mode are released to the broker when the flow closes. Additionally, in the QueueChannel configuration, the PollingConsumer’s processing thread is entirely separate from the adapter’s JCSMP subscriber thread — even a JMS-style “drain current message” behavior on the adapter would not be aware of work queued into the QueueChannel that the PollingConsumer has already dequeued and begun processing. The message left the adapter’s visibility at the moment it was placed into the QueueChannel; the adapter has no handle on what the PollingConsumer is currently executing.
The fix
Two complementary fixes:
Fix 1: Use a stable idempotency key. This is the critical fix. With a stable key, when Pod-B processes the redelivered M1 and calls Stripe with the same key, Stripe returns ch_A from its idempotency cache (within 24 hours) rather than committing ch_B. Pod-B then saves BillingRecord(stableKey, ch_A) and the customer is charged only once. With UUID.randomUUID(), each delivery generates a new key and a new Stripe charge; with a stable key, concurrent or sequential redeliveries always yield ch_A:
public void handleBillingEvent(Message<BillingEvent> message) {
BillingEvent event = message.getPayload();
AcknowledgmentCallback ackCallback =
StaticMessageHeaderAccessor.getAcknowledgmentCallback(message);
// Stable key: same value on any re-delivery of the same billing event
String stableKey = "billing:" + sha256(
event.getAccountId() + ":" + event.getBillingPeriod() + ":" + event.getAmountCents()
);
try {
if (billingRecordRepo.existsByIdempotencyKey(stableKey)) {
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
return;
}
Charge charge = Charge.create(
ChargeCreateParams.builder()
.setAmount(event.getAmountCents())
.setCurrency("eur")
.setCustomer(event.getCustomerId())
.build(),
RequestOptions.builder().setIdempotencyKey(stableKey).build()
);
billingRecordRepo.save(new BillingRecord(event, stableKey, charge.getId()));
ackCallback.acknowledge(AcknowledgmentCallback.Status.ACCEPT);
} catch (Exception e) {
ackCallback.acknowledge(AcknowledgmentCallback.Status.REQUEUE);
throw new MessageHandlingException(message, "billing failed", e);
}
}
Fix 2: Configure Spring Boot graceful shutdown with a drain phase longer than your Stripe p99. This reduces (but does not eliminate) the window in which a SIGTERM races with an in-flight Stripe call:
spring:
lifecycle:
timeout-per-shutdown-phase: 90s # >= Stripe 3DS p99 + DB write timeout
# In application.properties:
# spring.lifecycle.timeout-per-shutdown-phase=90s
Spring Boot’s embedded Tomcat / Undertow graceful shutdown drains active HTTP request-handling threads. The SolaceInboundChannelAdapter’s JCSMP flow is not directly covered by this setting, but if the PollingConsumer is wired through a Spring Integration flow that includes an ExecutorChannelInterceptor or a TaskExecutor with a custom shutdown hook, those executors can be drained before the adapter stops. The reliable fix remains the stable idempotency key: even with a 90-second drain timeout, a SIGTERM at exactly the wrong moment (after Stripe commits, before the ack is sent) can still occur. The stable key is the only mechanism that makes that race condition safe regardless of timing.
Cross-layer comparison: ack semantics and shutdown behavior across the SI and SCS Solace stack
| Layer | Auto-ack timing | Client-ack mechanism | Shutdown behavior on in-flight message | Tx integration |
|---|---|---|---|---|
SI Solace (SolaceInboundChannelAdapter + DirectChannel) |
After ServiceActivator.handleMessage() returns (synchronous) |
SolaceAcknowledgmentCallback.acknowledge(ACCEPT) — immediate JCSMP IMessage.ackMessage() |
FlowReceiver.close() — closes flow; CLIENT_ACK unacked msgs returned to Solace immediately; JMS-style drain not guaranteed |
No Spring TX integration; ack is unconditional network call regardless of TX state |
SI Solace (SolaceInboundChannelAdapter + ExecutorChannel) |
After ExecutorChannel.send() returns (before executor thread processes message) |
Same as above, but ack called on executor thread; requires explicit CLIENT_ACK + ack in handler |
Same as DirectChannel; QueueChannel items already dequeued to executor are orphaned |
No Spring TX integration |
SCS Solace Binder (requeueRejected=false) |
After all SCS max-attempts retries are exhausted; routes to DMQ |
Not exposed; binder controls ack internally | Binder-managed; messages buffered by SCS binder infrastructure | transactedSession=true uses JCSMP transacted session but is NOT XA; commits sequentially after Spring TX |
Raw JCSMP (FlowReceiver) |
N/A; developer calls IMessage.ackMessage() manually |
Direct IMessage.ackMessage() — immediate JCSMP network call |
FlowReceiver.close() — same as SI; unacked CLIENT_ACK messages returned to broker |
No Spring TX integration; JCSMPTransactedSession available but requires XA to coordinate with DB TX |
SI GCP PubSub (PubSubInboundChannelAdapter) |
After handler returns with ackMode=AUTO; ack deadline timer separate issue |
BasicAcknowledgeablePubsubMessage.ack() — immediate HTTP POST to GCP API |
Subscriber client stops; in-flight ack deadline may expire; GCP redelivers on deadline expiry | No Spring TX integration |
JMS (DefaultMessageListenerContainer + JmsTransactionManager) |
After session.commit() fires in TransactionSynchronization.afterCommit() |
session.commit() deferred by TX manager |
MessageConsumer.close() — JMS 2.0 spec §8.7 mandates drain of active onMessage() before return |
Full Spring TX integration via JmsTransactionManager |
Comparison: SCS Solace Binder failure modes vs Spring Integration Solace failure modes
The SCS Solace post in this series covers three binder-level failure modes. Each differs structurally from the three SI Solace modes above:
| Mode | SCS Solace Binder | Spring Integration Solace (this post) |
|---|---|---|
| Ack on successful processing path | Binder acks after all max-attempts retries; requeueRejected=false routes to DMQ; on-call re-enqueue → UUID_B → ch_B |
AUTO_ACK fires on channel.send() return; with ExecutorChannel this is before processing starts; DB exception → silent loss → reconciliation → UUID_B → ch_B |
| Transaction boundary | transactedSession=true: JCSMP JCSMPTransactedSession.commit() called sequentially AFTER Spring @Transactional commits; TCP RST drops COMMIT frame; JCSMP ClosedFacilityException; Solace requeues; redelivery → UUID_B → ch_B |
SolaceAcknowledgmentCallback.acknowledge(ACCEPT) inside @Transactional: immediate JCSMP call; rollback doesn't reverse ack; no BillingRecord; reconciliation → UUID_B → ch_B |
| Redelivery / replay | Solace Message Replay: 1,247 pre-outage already-charged events replayed with solace_redelivered=false (indistinguishable from first delivery); UUID.randomUUID() × 1,247 → ch_B × 1,247 |
SolaceInboundChannelAdapter.stop() + QueueChannel + CLIENT_ACK: FlowReceiver.close() releases unacked M1 immediately; Pod-B processes redelivered M1; UUID_B → ch_B (single immediate double-charge, not mass replay) |
Diagnostic checklist: Spring Integration Solace + Stripe idempotency
- What is the output channel type of your
SolaceInboundChannelAdapter? IfExecutorChannel,QueueChannel, orPriorityChannel,AcknowledgeMode.AUTO_ACKfires before theServiceActivatorprocesses the message. Switch toAcknowledgeMode.CLIENT_ACKand callacknowledge(ACCEPT)from within the handler, or useDirectChannel(synchronous, single-threaded) if throughput constraints allow. - Does your billing
ServiceActivatorcallUUID.randomUUID()at or near the Stripe API call? If yes, every re-delivery, retry, or reconciliation republish generates a new key and a new Stripe charge. Replace with a stableSHA-256(accountId + billingPeriod + amountCents)key derived from the message payload. The same value must be produced regardless of channel type, pod identity, or call count. - Is
SolaceAcknowledgmentCallback.acknowledge(ACCEPT)called inside a@Transactionalmethod? If yes, the ack fires as an immediate JCSMP network call regardless of whether the Spring transaction later rolls back. Moveacknowledge(ACCEPT)toTransactionSynchronizationManager.registerSynchronization().afterCommit(). Leave theREQUEUEpath in the transaction’s catch block or in theafterCompletion(STATUS_ROLLED_BACK)handler. - Does your application use Kubernetes rolling deployments, preStop hooks, or horizontal pod autoscaler scale-in? All three send SIGTERM. If any billing message may be mid-processing at SIGTERM time, a stable idempotency key is required because the JCSMP FlowReceiver close will redeliver the message to another pod. Configure
spring.lifecycle.timeout-per-shutdown-phase≥ Stripe p99 as a secondary defense. - Is a
BillingRecordrow keyed on the stable business key checked before the Stripe API call? A stable Stripe idempotency key alone handles re-deliveries within Stripe’s 24-hour window. Beyond 24 hours (reconciliation republish, Solace replay, DMQ re-ingest), only the application-layerexistsByIdempotencyKey()guard prevents a new charge. Both checks are required. - Does an
errorChannelhandler republish failed messages to the same or a retry Solace queue? If yes and the billing handler usesUUID.randomUUID(), each republished message generates a new key. If the original Stripe charge succeeded and only the DB write failed, the republish will commit a second charge. The stable key prevents this; theexistsByIdempotencyKey()guard handles the case where the DB write eventually succeeds on a retry. - Is the Solace queue configured as exclusive or non-exclusive? Non-exclusive queues allow multiple consumer instances to receive different messages. Under a FlowReceiver close (Mode 3), any non-exclusive queue consumer can immediately pick up the released message. For exclusive queues, redelivery only occurs after the exclusive consumer reconnects or another consumer is promoted. Exclusive queue configuration reduces the Mode 3 window but does not eliminate it if multiple adapter instances compete for the exclusive flow.
- What is the Solace queue’s
maxDeliveryCountand DMQ policy? With a stable key andREQUEUEon transient failures, redelivery count grows. IfmaxDeliveryCountis reached before the transient failure clears, the message routes to the DMQ. The DMQ re-ingestion path is safe with a stable key (Stripe returnsch_Afrom cache, orexistsByIdempotencyKey()guards). Without a stable key, DMQ re-ingestion generatesUUID_Band commitsch_B. Update runbook procedures to always check for an existing Stripe charge with the stable key before re-ingesting DMQ messages.
Test suite: JUnit 5 + WireMock + Testcontainers (Solace PubSub+ Docker)
Each failure mode can be verified in isolation with a test that controls: (a) the channel type and ack mode, (b) the Stripe response via WireMock, and (c) the database behavior via Testcontainers PostgreSQL or mocking.
@SpringBootTest
@Testcontainers
class SpringIntegrationSolaceStripeIdempotencyTest {
@Container
static final SolaceContainer solace =
new SolaceContainer("solace/solace-pubsub-standard:latest")
.withExposedPorts(55555, 8080);
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Autowired JCSMPSession jcsmpSession;
@Autowired BillingRecordRepository billingRecordRepo;
@Autowired SolaceInboundChannelAdapter billingAdapter;
static final String BILLING_QUEUE = "billing-events-queue";
// ── Mode 1: AUTO_ACK + ExecutorChannel → ack fires before processing ─────────
@Test
@DisplayName("Mode 1: AUTO_ACK + ExecutorChannel + DB exception → no BillingRecord, no redelivery (without stable key)")
void autoAck_executorChannel_dbException_noBillingRecordAndNoRedelivery()
throws Exception {
// Arrange: Stripe succeeds, DB throws
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));
when(billingRecordRepo.save(any()))
.thenThrow(new DataIntegrityViolationException("constraint"));
// Publish M1 to Solace queue
publishToBillingQueue(new BillingEvent("acct_001", "2026-10", 4900L));
// Wait for Stripe call to complete (adapter acks immediately, executor processes)
await().atMost(Duration.ofSeconds(5))
.until(() -> wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges"))).size() == 1);
// BillingRecord not saved — DB threw
assertThat(billingRecordRepo.count()).isZero();
// No redelivery: message was acked by AUTO_ACK on channel dispatch
// Verify no second Stripe call within 10s (which would indicate redelivery)
Thread.sleep(3_000);
assertThat(wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")))).hasSize(1);
}
@Test
@DisplayName("Mode 1: CLIENT_ACK + ExecutorChannel + stable key → DB exception triggers REQUEUE → Stripe returns ch_A on retry (with fix)")
void clientAck_executorChannel_dbException_requeueAndStableKeyDeduplicates()
throws Exception {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));
AtomicInteger saveAttempt = new AtomicInteger(0);
when(billingRecordRepo.save(any())).thenAnswer(inv -> {
if (saveAttempt.incrementAndGet() == 1) {
throw new DataIntegrityViolationException("constraint");
}
return inv.getArgument(0);
});
publishToBillingQueue(new BillingEvent("acct_002", "2026-10", 4900L));
// Wait for successful second delivery (REQUEUE → Solace redelivers)
await().atMost(Duration.ofSeconds(20))
.until(() -> billingRecordRepo.count() == 1);
// All Stripe calls used the same stable key
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
Set<String> keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1); // stable key → ch_A on all calls
}
// ── Mode 2: acknowledge(ACCEPT) inside @Transactional → rollback doesn't un-ack ──
@Test
@DisplayName("Mode 2: acknowledge(ACCEPT) inside @Transactional → rollback leaves no BillingRecord (without fix)")
void ackInsideTransactional_rollbackLeavesNoRecord() throws Exception {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));
when(outboxRepository.insertBillingEvent(any(), any(), any()))
.thenThrow(new DataIntegrityViolationException("unique constraint"));
publishToBillingQueue(new BillingEvent("acct_003", "2026-10", 4900L));
await().atMost(Duration.ofSeconds(5))
.until(() -> wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges"))).size() == 1);
assertThat(billingRecordRepo.count()).isZero();
// Message acked despite transaction rollback → no redelivery
Thread.sleep(3_000);
assertThat(wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")))).hasSize(1);
}
@Test
@DisplayName("Mode 2: acknowledge(ACCEPT) in afterCommit() + stable key → rollback prevents ack → Solace redelivers → one charge (with fix)")
void ackInAfterCommit_rollbackPreventsAck_redeliveryIsIdempotent() throws Exception {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_A"))));
AtomicInteger outboxAttempt = new AtomicInteger(0);
when(outboxRepository.insertBillingEvent(any(), any(), any())).thenAnswer(inv -> {
if (outboxAttempt.incrementAndGet() == 1) {
throw new DataIntegrityViolationException("constraint");
}
return null;
});
publishToBillingQueue(new BillingEvent("acct_004", "2026-10", 4900L));
await().atMost(Duration.ofSeconds(20))
.until(() -> billingRecordRepo.count() == 1);
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
Set<String> keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1); // stable key — ch_A on all attempts
}
// ── Mode 3: stop() + QueueChannel + CLIENT_ACK → FlowReceiver close redelivers ──
@Test
@DisplayName("Mode 3: FlowReceiver.close() with unacked QueueChannel message → Solace redelivers → UUID_B → ch_B (without stable key)")
void flowReceiverClose_unackedQueueChannelMessage_causesDuplicateCharge()
throws Exception {
// First call: Stripe commits ch_A; second call: Stripe commits ch_B
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("shutdown")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse()
.withFixedDelay(1_000) // simulate slow Stripe call
.withStatus(200)
.withBody(chargeJson("ch_A")))
.willSetStateTo("second"));
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.inScenario("shutdown")
.whenScenarioStateIs("second")
.willReturn(aResponse().withStatus(200).withBody(chargeJson("ch_B"))));
publishToBillingQueue(new BillingEvent("acct_005", "2026-10", 4900L));
// Wait for first Stripe call to start, then simulate shutdown
await().atMost(Duration.ofSeconds(3))
.until(() -> wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges"))).size() >= 1);
// Simulate FlowReceiver.close() — releases unacked M1 to Solace
billingAdapter.stop();
// Wait for second delivery to complete
await().atMost(Duration.ofSeconds(10))
.until(() -> wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges"))).size() >= 2);
// Two distinct keys → two charges (without stable key)
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
Set<String> keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(2); // UUID_A and UUID_B
}
@Test
@DisplayName("Mode 3: stable key → FlowReceiver.close() redelivery is safe → Stripe returns ch_A on both calls (with fix)")
void stableKey_flowReceiverCloseRedelivery_onlyOneCharge() throws Exception {
wireMock.stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withFixedDelay(1_000)
.withStatus(200)
.withBody(chargeJson("ch_A"))));
publishToBillingQueue(new BillingEvent("acct_006", "2026-10", 4900L));
await().atMost(Duration.ofSeconds(3))
.until(() -> wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges"))).size() >= 1);
billingAdapter.stop();
await().atMost(Duration.ofSeconds(10))
.until(() -> billingRecordRepo.count() == 1);
List<LoggedRequest> requests = wireMock.findAll(
postRequestedFor(urlEqualTo("/v1/charges")));
Set<String> keys = requests.stream()
.map(r -> r.getHeader("Idempotency-Key"))
.collect(Collectors.toSet());
assertThat(keys).hasSize(1); // one stable key → ch_A on all redeliveries
}
private void publishToBillingQueue(BillingEvent event) throws JCSMPException {
TextMessage msg = JCSMPFactory.onlyInstance().createMessage(TextMessage.class);
msg.setText(objectMapper.writeValueAsString(event));
Queue queue = JCSMPFactory.onlyInstance().createQueue(BILLING_QUEUE);
Producer producer = jcsmpSession.createProducer(null);
producer.send(msg, queue);
producer.close();
}
private String chargeJson(String chargeId) {
return """
{
"id": "%s",
"object": "charge",
"amount": 4900,
"currency": "eur",
"status": "succeeded"
}
""".formatted(chargeId);
}
}
Run with SolaceContainer from the testcontainers-solace module or the community solace/solace-pubsub-standard Docker image. The Solace PubSub+ Standard Edition image provides full JCSMP support for queue, ack, and guaranteed message delivery behavior in an isolated container. The Mode 3 test simulates FlowReceiver.close() by calling billingAdapter.stop() directly; in production, this is triggered by Spring’s graceful shutdown on SIGTERM.
How this relates to Keybrake
The three failure modes in this post share the same root shape as every other post in this series: the billing consumer reaches Stripe and commits a charge, then an infrastructure or application event causes a second Stripe call with a different idempotency key. The modes are different each time — ack timing on channel dispatch, transactional rollback after JCSMP ack, FlowReceiver close on shutdown — but the double-charge is the same outcome.
Keybrake adds a proxy-layer audit trail at the point where the charge is committed. When your SolaceInboundChannelAdapter’s billing handler routes through proxy.keybrake.com/stripe/v1/charges, every charge call is logged with its idempotency key, the Stripe charge ID, the timestamp, and the customer ID. If you see two audit entries with two different idempotency keys for the same customer and billing period — the fingerprint of all three failure modes in this post — you have an actionable alert before the customer files a dispute. Keybrake’s per-customer daily spend cap also provides a circuit-breaker for the worst-case scenario (an auto-requeue loop on a transient DB failure with UUID.randomUUID()): once the customer hits the daily cap, all subsequent charge attempts are rejected at the proxy layer, capping the blast radius at a configurable dollar amount rather than at whatever the DB failure window turns out to be.
Catch duplicate Stripe charges before your customers do
Keybrake proxies your agent’s Stripe calls with per-customer spend caps, idempotency-key audit log, and one-click revoke. One config line — no Stripe account changes needed.
Summary
Spring Integration’s SolaceInboundChannelAdapter introduces three failure modes that produce duplicate Stripe charges, each arising from a behavior that differs structurally from the raw JCSMP FlowReceiver, the SCS Solace Binder, and from JMS-based messaging systems that developers commonly migrate from:
- Mode 1 —
AcknowledgeMode.AUTO_ACK+ExecutorChannel: The adapter’s AUTO_ACK fires whenchannel.send()returns, not when the downstreamServiceActivatorcompletes. WithExecutorChannel,channel.send()returns immediately after enqueuing the task, before the executor thread picks it up. A DB exception in the ServiceActivator after a successful Stripe commit leaves noBillingRecordand produces no redelivery. Reconciliation republish 72 hours later generatesUUID_B; Stripe commitsch_B. Fix: useAcknowledgeMode.CLIENT_ACK; callacknowledge(ACCEPT)only after the handler succeeds; use a stable content-derived key. - Mode 2 —
acknowledge(ACCEPT)inside@Transactional:SolaceAcknowledgmentCallback.acknowledge()wraps a direct JCSMPIMessage.ackMessage()network call that fires immediately, unconditionally, regardless of whether an enclosing Spring transaction will later roll back. A DB write failure after a successful Stripe charge rolls back the database but not the already-committed Solace ack. NoBillingRecord. Reconciliation republish generatesUUID_B; Stripe commitsch_B. Fix: moveacknowledge(ACCEPT)toTransactionSynchronizationManager.registerSynchronization().afterCommit(); use a stable key so redeliveries are idempotent. - Mode 3 —
SolaceInboundChannelAdapter.stop()+QueueChannel+CLIENT_ACK: Callingstop()during Kubernetes graceful shutdown closes the JCSMPFlowReceiver. JCSMP immediately returns all unacknowledged CLIENT_ACK messages to the Solace broker. A message that was picked from theQueueChannelby thePollingConsumerand is mid-processing in theServiceActivatoris released to Solace and redelivered to another pod. That pod generatesUUID_B; Stripe commitsch_B. Fix: stable key makes concurrent redelivery safe — Stripe returnsch_Afrom its idempotency cache;existsByIdempotencyKey()guards the application layer beyond the 24-hour window.
The stable idempotency key is the fix for all three modes: a deterministic SHA-256(accountId + billingPeriod + amountCents) of the billing event’s business fields, producing the same string on every re-delivery, reconciliation republish, and concurrent duplicate delivery. Combined with a BillingRecord guard in the application layer, it makes the billing ServiceActivator safe under any re-invocation scenario that Spring Integration, Solace JCSMP, and Stripe’s 24-hour idempotency cache can produce.
The next post in this series covers Spring Integration IBM MQ + Stripe idempotency failure modes — how IBM MQ’s JMS provider, XA transaction manager integration, and message backout queue behavior interact with UUID.randomUUID() to produce duplicate charges in ways specific to the MQ JMS layer.