0

I have a test method which sometimes fails during deploy and sometimes does not. I have never seen it fail on my local. You can see my code below.

I have the following retry mechanism which is asynchronously called from another service:

@Transactional
public boolean retry(NotificationOrder order) {
    notificationService.send(order);
    return true;
}

public void resolveOnFailedAttempt(Long orderId) {  //automatically called if `retry` method fails
    notificationOrderCommonTransactionsService.updateNotificationOrderRetryCount(orderId);
}

The notification service is like this :

@Service
@RequiredArgsConstructor
public class NotificationServiceImpl implements NotificationService {

    private final NotificationOrderCommonTransactionsService notificationOrderCommonTransactionsService;

    @Override
    @Transactional
    public NotificationResponse send(NotificationOrder order) {
        NotificationRequest request;
        try {
            request = prepareNotificationRequest(order);
        } catch (Exception e) {
            notificationOrderCommonTransactionsService.saveNotificationOrderErrorMessage(order.getId(),
                          e.getMessage());
            throw e;
        }

        ...
 
        return response;
    }

        private void prepareNotificationRequest(NotificationOrder order) {
            ...
            throw new Exception("ERROR");
        }
}


And the commmon transactions service is like this :

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public NotificationOrder saveNotificationOrderErrorMessage(Long orderId, String errorMessage) {
        NotificationOrder order = notificationRepository.findOne(orderId);
        order.setErrorDescription(errorMessage);
        notificationRepository.save(order);
        return order;
    }

    public NotificationOrder updateNotificationOrderRetryCount(Long orderId) {
        NotificationOrder order = notificationRepository.findOne(orderId);
        order.setRetryCount(order.getRetryCount() + 1);
        order.setOrderStatus(NotificationOrderStatus.IN_PROGRESS);
        notificationRepository.save(order);
        return order;
    }

Here is my integration test :

    @Test
    public void test() {
 
        NotificationOrderRequest invalidRequest = invalidRequest();

        ResponseEntity<NotificationOrderResponse> responseEntity = send(invalidRequest);

        NotificationOrder notificationOrder = notificationOrderRepository.findOne(1);

        softly.assertThat(notificationOrder.getOrderStatus().isEqualTo(NotificationOrderStatus.IN_PROGRESS))
        softly.assertThat(notificationOrder.getErrorDescription())
                 .isEqualTo("ERROR");  //This the line that fails.

        softly.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

In the test method it is confirmed that updateNotificationOrderRetryCount is called and the order status is updated as IN_PROGRESS. However, the error message is null and I get the following assertion error :

    -- failure 1 --
    Expecting:
     <null>
    to be equal to:
     <"ERROR">
    but was not.

I expect saveNotificationOrderErrorMessage transaction to be completed and the changes to be committed before updateNotificationOrderRetryCount method is called. But it seems like it does work that way. Could anyone help me find out why my code behave like this ?

How can I reproduce this error on my local? And what can I do to fix it ?

Thanks.

byksl
  • 106
  • 7
  • What kind of exception are you actually throwing in the `prepareNotificationRequest`an `send` methods. Is any kind of `RuntimeException`? It is not declared in the methods signature. – jccampanero Feb 08 '21 at 12:23
  • I am throwing an exception of a class which extends `RuntimeException`. – byksl Feb 08 '21 at 16:40
  • Is it safe to assume that the id of your notification order is always going to be 1? Are there other tests using the same repository? What's the life-cycle of your test data store? – Hopey One Feb 15 '21 at 03:55

2 Answers2

1

If the code snippet of the question is accurate, pay attention to the fact that you are rethrowing the exception raised in the prepareNotificationRequest method, I assume in order to enable the retry mechanism:

NotificationRequest request;
try {
    request = prepareNotificationRequest(order);
} catch (Exception e) {
    notificationOrderCommonTransactionsService.saveNotificationOrderErrorMessage(order.getId(),
                  e.getMessage());
    throw e; // You are rethrowing the exception
}

For your comment, the exception thrown extends RuntimeException.

As the Spring documentation indicates:

In its default configuration, the Spring Framework’s transaction infrastructure code marks a transaction for rollback only in the case of runtime, unchecked exceptions. That is, when the thrown exception is an instance or subclass of RuntimeException. ( Error instances also, by default, result in a rollback). Checked exceptions that are thrown from a transactional method do not result in rollback in the default configuration.

Probably Spring is performing rollback of the initial transaction, that one associated with saveNotificationOrderErrorMessage. I realize that this method is annotated as @Transactional(propagation = Propagation.REQUIRES_NEW) and that it is initiating a new transaction, but perhaps the problem could be related with it.

When the retry mechanism takes place, another transaction, associated with the invocation of the method updateNotificationOrderRetryCount is performed, and this transaction is successfully committed. This is the reason why the changes performed in this second method are properly committed.

The solution of the problem will depend on how your retry mechanism is implemented, but you can, for example, raise the original exception and, as a first step in the retry mechanism, trace the problem in the database or, raise a checked exception - Spring by default will not perform rollback for it - and handle it as appropriate.

Update

Another possible reason of the problem could be the transaction demarcations in the send method.

This method is annotated as @Transactional. As a consequence, Spring will initiate a new transaction for it.

The error occurs, and you trace the error in the database, in a new transaction but please, be aware that the initial transaction is still there.

Although not described in your code, in some way, the retry mechanism takes place, and updates the retry count. It this operation is performed within the initial transaction (or a higher level one), due to the transaction boundaries, database isolation levels, and related stuff, it is possible that this transaction, the initial, fetch an actually outdated, but current from the transaction boundary point of view, NotificationOrder. And this information is the one that finally is committed, overwriting the information of the error.

One simple solution, maybe for both possibilities, could be to include the error message in the updateNotificationOrderRetryCount method itself, reducing the problem to a single transaction:

/* If appropriate, mark it as Transactional */
@Transactional
public NotificationOrder updateNotificationOrderRetryCount(Long orderId, String errorMessage) {
  NotificationOrder order = notificationRepository.findOne(orderId);
  order.setRetryCount(order.getRetryCount() + 1);
  order.setOrderStatus(NotificationOrderStatus.IN_PROGRESS);
  order.setErrorDescription(errorMessage);
  // It is unnecessary, all the changes performed in the entity within the transaction will be committed
  // notificationRepository.save(order); 
  return order;
}
halfer
  • 19,824
  • 17
  • 99
  • 186
jccampanero
  • 50,989
  • 3
  • 20
  • 49
  • Thanks for your answer. `saveNotificationOrderErrorMessage` has an annotation on it `@Transactional(propagation = Propagation.REQUIRES_NEW)`. This prevents the rollback because the operation is performed in a new transaction and is not affected by the exception. By the way, this test failure does not happen all the time. It sometimes works and sometimes not. – byksl Feb 08 '21 at 16:58
  • Thank you for your comment @byksl. It could be possible although please, be aware that the `send` method is annotated itself as `@Transactional` so perhaps it is defining the whole transaction behavior in some way, although the method `saveNotificationOrderErrorMessage` is annotated with `REQUIRES_NEW`. I always have found confusing to mix several transaction demarcations within a single method invocation. Please, if possible, try to raise a different kind of exception and see if it works, I think it will probably do, at least if it resembles your test. – jccampanero Feb 08 '21 at 17:09
0

Try enabling SQL logging and parameter bind logging and look through the statements. I don't know all of your code, but maybe your are setting the message to null somewhere? It could also be, that the actions are interleaving somehow such that updateNotificationOrderRetryCount is called before/while saveNotificationOrderErrorMessage in a way that causes this. If both run right before commit, but saveNotificationOrderErrorMessage commits before updateNotificationOrderRetryCount, you could see the error message being overwritten with null.

Christian Beikov
  • 15,141
  • 2
  • 32
  • 58
  • I am not setting the message to null anywhere in my code. You said "If both run right before commit, but saveNotificationOrderErrorMessage commits before updateNotificationOrderRetryCount, you could see the error message being overwritten with null." How can I reproduce this on my local? What can I do to prevent it ? Any help is appreciated. Thanks. – byksl Feb 08 '21 at 16:50
  • You could set a breakpoint that only stops the current thread instead of the whole VM and see let the other thread progress to force this ordering. If this is possible, you should be able see this happening. – Christian Beikov Feb 09 '21 at 08:17