Question about @MigrationTask(onFailure = CONTINUE/MARK_RUN)

We have the following scenario:

We have a migration that creates documents in the database.
On a fresh database, creating a document fails because a required field is missing (it gets added by a later migration).
This is expected and intentional, that’s why we set onFailure = CONTINUE/MARK_RUN, so the migration is simply skipped and a later migration takes care of it properly.

The problem: CONTINUE or MARK_RUN doesn’t work. Instead, the server fails to start with an UnexpectedRollbackException.

Our understanding of why: documentService.create() runs within the transaction that A12 opens for the migration task. When create() fails, Spring internally marks that transaction as “must roll back”. When A12 then tries to continue (CONTINUE or MARK_RUN), it fails because Spring no longer allows the transaction to be committed.

Question: Is this known behavior? And is there a recommended way to write a migration that is allowed to fail on a fresh database without blocking server startup?

Hi,
my first suggestion would be to implement the migration steps in a way that the models always support the documents that should be created in each step.

Having said this, a possibility to avoid this problem would be to use one of the properties

  • mgmtp.a12.dataservices.documents.validation.enabled
  • mgmtp.a12.dataservices.documents.validation.skipForModels

to switch off validation during migration.

Of course, this is a workaround. In general, having specified onFailure = CONTINUE/MARK_RUN should not be in conflict with by Spring transaction handling.

I will investigate this more deeply, and give you notice.

Here is what Claude Sonnet 4.6 says:

Question:
Given that in this class the method executeTask runs in the catch block, and ErrorHandling.MARK_RUN is specified.

Is there any chance that Spring marks the current transaction as “must roll back”, so that subsequent commits inside this transaction are not possible?


Answer: No risk of “rollback-only” poisoning the outer transaction

The key protection is REQUIRES_NEW.

In migrate() (line 96), each migration step is dispatched via:

transactionHandler.runMethodInNewTransaction(() -> processMigrationStep(migrationStep));

runMethodInNewTransaction is annotated with @Transactional(propagation = Propagation.REQUIRES_NEW), which suspends the outer transaction (the one from migrate()) and opens a completely independent transaction for each step.

Inside executeTask (line 225–259), the catch block for MARK_RUN calls persistMigrationStep, which does migrationStepRepository.save(step) — all within that inner REQUIRES_NEW transaction.

The “rollback-only” risk would only exist if…

Spring marks a transaction as rollback-only when an exception propagates out of a @Transactional boundary. This would poison an outer transaction that participates in the same transaction (propagation REQUIRED).

But here:

  1. The exception is caught inside executeTask — it never propagates out of the REQUIRES_NEW transaction boundary.
  2. Even if it did propagate, REQUIRES_NEW has its own independent transaction — rolling it back does not mark the outer migrate() transaction as rollback-only, because they are completely separate transactions.

Summary

Scenario Risk
Exception caught in catch block, MARK_RUN path executes save() :white_check_mark: Safe — exception is swallowed, transaction is healthy
Exception escapes executeTask somehow :white_check_mark: Still safe — REQUIRES_NEW isolates each step; outer transaction is unaffected
HALT path (throws MigrationException) The inner REQUIRES_NEW transaction rolls back, but the outer migrate() transaction would then also see the unchecked exception propagate through it and roll back as well — but that’s intentional

So for MARK_RUN, no, Spring cannot mark any subsequent-commit-relevant transaction as rollback-only due to this code path.


Further investigation gives:


If a user’s migration task method is annotated with @Transactional, the following chain happens:

  1. runMethodInNewTransaction opens a REQUIRES_NEW transaction (Transaction A).
  2. Inside that, executeTask calls executeMigrationTaskmethod.invoke(stepInstance, ...).
  3. Because stepInstance is a Spring bean and the method is @Transactional, Spring’s AOP proxy intercepts the call and opens another nested transaction (Transaction B) with default propagation REQUIRED — meaning it joins Transaction A.
  4. The task method throws an exception.
  5. Spring’s @Transactional interceptor catches the exception and marks Transaction A as rollback-only (because B joined A, rolling back B = rolling back A).
  6. Control returns to executeTask’s catch (Exception e) block — the exception is swallowed.
  7. For MARK_RUN, persistMigrationStep tries to save()but Transaction A is already marked rollback-only.
  8. When runMethodInNewTransaction returns, Spring tries to commit Transaction A → UnexpectedRollbackException.

But there’s an even simpler scenario that affects all users (even without @Transactional on the task method):

If the migration task calls any Spring service annotated with @Transactional (e.g., a repository or service), and that call throws, the same thing happens: Spring’s transaction interceptor marks the active transaction (Transaction A) rollback-only before the exception reaches executeTask’s catch block.

This is a fundamental Spring transaction behavior: once a @Transactional boundary has intercepted an exception, the transaction is poisoned, regardless of whether the caller catches the exception.