AbortParticipant and SendResponse
The problem with the happy path only
A naive implementation of response sending looks like this:
// Participant: SendResponse (wrong approach)
public void commit(long id, Serializable context) {
Context ctx = (Context) context;
ISOSource src = ctx.get(SOURCE.toString());
ISOMsg resp = ctx.get(RESPONSE.toString());
src.send(resp); // only fires on commit
}
This works fine when everything succeeds. But when an earlier participant aborts the transaction, commit() is never called — and the client waits forever for a response that will never arrive. The TCP connection eventually times out, but that's a poor user experience and a significant operational problem.
The client must always receive a response, regardless of whether the transaction committed or aborted.
AbortParticipant
AbortParticipant extends TransactionParticipant with one additional entry point:
public interface AbortParticipant extends TransactionParticipant {
default int prepareForAbort(long id, Serializable context) {
return prepare(id, context); // default: same as prepare
}
}
When the TM detects that the transaction will abort — because a participant returned ABORTED — it switches from calling prepare() to calling prepareForAbort() on any subsequent AbortParticipants it encounters in the chain.
The critical insight: prepareForAbort() is called even though the transaction is aborting. This lets SendResponse decide whether sending a response is appropriate, and then actually do so via abort().
SendResponse in detail
public class SendResponse implements AbortParticipant, Configurable {
public int prepare(long id, Serializable context) {
Context ctx = (Context) context;
ISOSource source = ctx.get(this.source);
if (abortOnClosed && (source == null || !source.isConnected())) {
ctx.log(this.source + " not present or no longer connected");
return ABORTED | READONLY | NO_JOIN;
}
return PREPARED | READONLY;
}
public void commit(long id, Serializable context) {
sendResponse(id, (Context) context); // happy path
}
public void abort(long id, Serializable context) {
sendResponse(id, (Context) context); // abort path — same method!
}
private void sendResponse(long id, Context ctx) {
ISOSource src = ctx.get(source);
ISOMsg resp = ctx.get(response);
// guards: inhibit flag, TX not null, resp null, src not connected
src.send(resp);
}
}
What prepare() does
- Checks that
SOURCEis still in the context and the connection is still open. - If the client disconnected before we could respond (
!source.isConnected()), returnsABORTED | READONLY | NO_JOIN— there is nobody to send a response to, so joining makes no sense. - Otherwise returns
PREPARED | READONLY— both on the happy path (called fromprepare()) and the abort path (called fromprepareForAbort(), which by default delegates toprepare()).
READONLY on SendResponse
SendResponse also uses READONLY. It reads SOURCE and RESPONSE from the context but does not modify the context itself. The TM does not need to persist a snapshot.
commit() and abort() are identical
Both call sendResponse(). This is intentional: the response content was determined by earlier participants (0000 on success, 9100 on decline) and placed in the context. SendResponse is only responsible for delivery, not for deciding what to send. The decision was already made during the prepare phase.
Guards inside sendResponse()
Before calling src.send(resp), SendResponse checks three conditions:
| Condition | Action |
|---|---|
ctx.getResult().hasInhibit() | Log RESPONSE INHIBITED, skip send |
ctx.get(TX.toString()) != null | Log PANIC - TX not null, skip send. A non-null TX means an open database transaction was not committed — sending a response now would be a data integrity violation. |
resp == null | Log RESPONSE not present, skip send |
These guards protect against participants that set an inhibit flag (e.g. a duplicate detection participant that decided this is a replay) or that leave an open database handle in the context.
The full abort lifecycle
Here is the complete sequence for a transaction where HandleNMM declines with 9100:
1. TM picks up Context from queue
2. TM calls HandleNMM.prepare()
→ builds 2810/9100 response, puts in ctx.RESPONSE
→ returns ABORTED | READONLY
3. TM sees ABORTED. HandleNMM returned READONLY so it did not join.
Abort flag is now set.
4. TM calls SendResponse.prepareForAbort()
(default impl → delegates to prepare())
→ SOURCE is connected → returns PREPARED | READONLY
→ SendResponse joins the transaction
5. Prepare chain complete. No more participants.
6. TM calls abort() on joined participants (reverse order):
→ SendResponse.abort() → sendResponse() → src.send(2810/9100)
→ client receives the decline response
7. TM logs:
<txn id="2" rc="ABORTED" elapsed="2ms">
<handle-nmm>decline f70=999 (unknown function code)</handle-nmm>
</txn>
A typical production participant chain
In production, the abort path carries the same weight as the happy path:
<participant class="com.example.DecodeRequest" realm="decode" />
<participant class="com.example.LookupCard" realm="card" />
<participant class="com.example.CheckDuplicate" realm="dup-check" />
<participant class="com.example.AuthorizeOnline" realm="authorize" />
<participant class="com.example.RecordTransaction" realm="record" />
<participant class="org.jpos.transaction.participant.SendResponse"
realm="send-response" />
If LookupCard returns ABORTED (card not found), then:
DecodeRequest.abort()runs (cleanup if needed)SendResponse.prepareForAbort()runs → joinsSendResponse.abort()runs → sends whatever is inRESPONSE
LookupCard is responsible for putting an appropriate error response in the context before returning ABORTED. This is the established jPOS pattern: the participant that decides the outcome also sets the response.