Skip to content

Commit 76365cb

Browse files
committed
FINERACT-2570: Defer webhook/hook event publication to after transaction commit in batch requests with enclosingTransaction=true
1 parent b26571b commit 76365cb

File tree

2 files changed

+122
-2
lines changed

2 files changed

+122
-2
lines changed

fineract-core/src/main/java/org/apache/fineract/commands/service/SynchronousCommandProcessingService.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
import org.apache.fineract.useradministration.domain.AppUser;
6363
import org.springframework.context.ApplicationContext;
6464
import org.springframework.stereotype.Service;
65+
import org.springframework.transaction.support.TransactionSynchronization;
66+
import org.springframework.transaction.support.TransactionSynchronizationManager;
6567

6668
@Service
6769
@Slf4j
@@ -197,8 +199,23 @@ private CommandProcessingResult executeCommand(final CommandWrapper wrapper, fin
197199
}
198200

199201
result.setRollbackTransaction(null);
200-
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result); // TODO must be performed in a
201-
// new transaction
202+
203+
// When running inside an enclosing batch transaction, defer hook publication
204+
// until after the transaction commits. This prevents webhooks from firing for
205+
// commands that are subsequently rolled back when a later command in the batch
206+
// fails (e.g. a withdrawal succeeds but its fee charge fails, rolling back both).
207+
if (isEnclosingTransaction && TransactionSynchronizationManager.isSynchronizationActive()) {
208+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
209+
210+
@Override
211+
public void afterCommit() {
212+
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result);
213+
}
214+
});
215+
} else {
216+
publishHookEvent(wrapper.entityName(), wrapper.actionName(), command, result);
217+
}
218+
202219
return result;
203220
}
204221

fineract-provider/src/test/java/org/apache/fineract/commands/service/SynchronousCommandProcessingServiceTest.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020

2121
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
2222
import static org.junit.jupiter.api.Assertions.assertEquals;
23+
import static org.junit.jupiter.api.Assertions.assertFalse;
2324
import static org.junit.jupiter.api.Assertions.assertThrows;
2425
import static org.junit.jupiter.api.Assertions.assertTrue;
2526
import static org.mockito.ArgumentMatchers.anyString;
2627
import static org.mockito.Mockito.any;
2728
import static org.mockito.Mockito.atLeast;
2829
import static org.mockito.Mockito.doAnswer;
2930
import static org.mockito.Mockito.mock;
31+
import static org.mockito.Mockito.never;
3032
import static org.mockito.Mockito.reset;
3133
import static org.mockito.Mockito.times;
3234
import static org.mockito.Mockito.verify;
@@ -52,6 +54,7 @@
5254
import org.apache.fineract.infrastructure.core.api.JsonCommand;
5355
import org.apache.fineract.infrastructure.core.config.FineractProperties;
5456
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
57+
import org.apache.fineract.infrastructure.core.domain.BatchRequestContextHolder;
5558
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
5659
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException;
5760
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
@@ -67,6 +70,7 @@
6770
import org.mockito.Spy;
6871
import org.springframework.context.ApplicationContext;
6972
import org.springframework.lang.NonNull;
73+
import org.springframework.transaction.support.TransactionSynchronizationManager;
7074
import org.springframework.web.context.request.RequestContextHolder;
7175
import org.springframework.web.context.request.ServletRequestAttributes;
7276

@@ -598,4 +602,103 @@ public void testExecuteCommandWithMaxRetryFailure() {
598602
underTest.executeCommand(commandWrapper, jsonCommand, false);
599603
verify(commandSourceService, times(4)).getCommandSource(commandId);
600604
}
605+
606+
/**
607+
* Test that when running inside an enclosing batch transaction, hook events are NOT published immediately but
608+
* deferred to afterCommit. This prevents webhooks (e.g. SMS notifications) from firing for commands that are
609+
* subsequently rolled back when a later command in the batch fails.
610+
*/
611+
@Test
612+
public void testHookEventDeferredInEnclosingTransaction() {
613+
CommandWrapper commandWrapper = getCommandWrapper();
614+
615+
long commandId = 1L;
616+
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
617+
when(jsonCommand.commandId()).thenReturn(commandId);
618+
619+
NewCommandSourceHandler commandHandler = Mockito.mock(NewCommandSourceHandler.class);
620+
CommandProcessingResult commandProcessingResult = Mockito.mock(CommandProcessingResult.class);
621+
when(commandProcessingResult.isRollbackTransaction()).thenReturn(false);
622+
when(commandHandler.processCommand(jsonCommand)).thenReturn(commandProcessingResult);
623+
when(commandHandlerProvider.getHandler(Mockito.any(), Mockito.any())).thenReturn(commandHandler);
624+
625+
when(configurationDomainService.isMakerCheckerEnabledForTask(Mockito.any())).thenReturn(false);
626+
String idk = "idk";
627+
when(idempotencyKeyResolver.resolve(commandWrapper)).thenReturn(idk);
628+
CommandSource commandSource = Mockito.mock(CommandSource.class);
629+
when(commandSource.getId()).thenReturn(commandId);
630+
when(commandSourceService.findCommandSource(commandWrapper, idk)).thenReturn(null);
631+
when(commandSourceService.getCommandSource(commandId)).thenReturn(commandSource);
632+
633+
AppUser appUser = Mockito.mock(AppUser.class);
634+
when(context.authenticatedUser(Mockito.any(CommandWrapper.class))).thenReturn(appUser);
635+
when(commandSourceService.saveInitialNewTransaction(commandWrapper, jsonCommand, appUser, idk)).thenReturn(commandSource);
636+
when(commandSourceService.saveResultSameTransaction(commandSource)).thenReturn(commandSource);
637+
when(commandSource.getStatus()).thenReturn(CommandProcessingResultType.PROCESSED.getValue());
638+
639+
when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false))
640+
.thenReturn(commandProcessingResult);
641+
642+
// Simulate enclosing batch transaction with active synchronization
643+
BatchRequestContextHolder.setIsEnclosingTransaction(true);
644+
TransactionSynchronizationManager.initSynchronization();
645+
try {
646+
underTest.executeCommand(commandWrapper, jsonCommand, false);
647+
648+
// Hook event should NOT have been published immediately
649+
verify(applicationContext, never()).publishEvent(any());
650+
651+
// It should be registered as an afterCommit synchronization
652+
assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size());
653+
} finally {
654+
TransactionSynchronizationManager.clearSynchronization();
655+
BatchRequestContextHolder.resetIsEnclosingTransaction();
656+
}
657+
}
658+
659+
/**
660+
* Test that when NOT in an enclosing transaction, no TransactionSynchronization is registered. The code takes the
661+
* immediate path (not the deferred path), preserving existing behaviour for non-batch single-request commands.
662+
*/
663+
@Test
664+
public void testHookEventNotDeferredOutsideEnclosingTransaction() {
665+
CommandWrapper commandWrapper = getCommandWrapper();
666+
667+
long commandId = 1L;
668+
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
669+
when(jsonCommand.commandId()).thenReturn(commandId);
670+
when(jsonCommand.json()).thenReturn(null); // null causes publishHookEvent to exit early; avoids mocking the
671+
// full hook-serialisation chain
672+
673+
NewCommandSourceHandler commandHandler = Mockito.mock(NewCommandSourceHandler.class);
674+
CommandProcessingResult commandProcessingResult = Mockito.mock(CommandProcessingResult.class);
675+
when(commandProcessingResult.isRollbackTransaction()).thenReturn(false);
676+
when(commandHandler.processCommand(jsonCommand)).thenReturn(commandProcessingResult);
677+
when(commandHandlerProvider.getHandler(Mockito.any(), Mockito.any())).thenReturn(commandHandler);
678+
679+
when(configurationDomainService.isMakerCheckerEnabledForTask(Mockito.any())).thenReturn(false);
680+
String idk = "idk";
681+
when(idempotencyKeyResolver.resolve(commandWrapper)).thenReturn(idk);
682+
CommandSource commandSource = Mockito.mock(CommandSource.class);
683+
when(commandSource.getId()).thenReturn(commandId);
684+
when(commandSourceService.findCommandSource(commandWrapper, idk)).thenReturn(null);
685+
when(commandSourceService.getCommandSource(commandId)).thenReturn(commandSource);
686+
687+
AppUser appUser = Mockito.mock(AppUser.class);
688+
when(context.authenticatedUser(Mockito.any(CommandWrapper.class))).thenReturn(appUser);
689+
when(commandSourceService.saveInitialNewTransaction(commandWrapper, jsonCommand, appUser, idk)).thenReturn(commandSource);
690+
when(commandSourceService.saveResultSameTransaction(commandSource)).thenReturn(commandSource);
691+
when(commandSource.getStatus()).thenReturn(CommandProcessingResultType.PROCESSED.getValue());
692+
693+
when(commandSourceService.processCommand(commandHandler, jsonCommand, commandSource, appUser, false))
694+
.thenReturn(commandProcessingResult);
695+
696+
// Not in enclosing transaction (default)
697+
BatchRequestContextHolder.resetIsEnclosingTransaction();
698+
699+
underTest.executeCommand(commandWrapper, jsonCommand, false);
700+
701+
// The deferred path was not taken: no TransactionSynchronization should have been registered.
702+
assertFalse(TransactionSynchronizationManager.isSynchronizationActive());
703+
}
601704
}

0 commit comments

Comments
 (0)