Blog

CaseInstance Migration mit Camunda BPM

In Prozessautomatisierungsprojekten ist es nichts Neues, dass man sich bei langlaufenden Prozessen um die Migration von Prozessinstanzen kümmern muss, wenn sich während der Lebensdauer einer Instanz das zugrunde liegende Prozessmodell ändert und in einer neuen Version zur Verfügung steht.

Auch viele Toolhersteller haben das erkannt und bieten dafür Unterstützung an. Für Camunda BPM beispielsweise gibt es neben grafischer Migrationsunterstützung im Cockpit und einer Migration API sogar eine eigene Migration Extension, die das Erstellen von stageunabhängigen Migrationsplänen erlaubt.

Alles im Lot also für Prozesse. Doch wo wäre Migrationsbedarf größer als bei Cases, die, geschaffen für die Unterstützung manueller Fallbearbeitung, fast per Definition langlebig sind? Und soll Case Management nicht sogar „adaptiv“ sein, so dass jederzeit neue Aktivitäten dem Modell hinzugefügt werden können? Jawohl! Sucht man nach Toolunterstützung für die Migration von Caseinstanzen, schaut man meist in die Röhre. Sogar bei Camunda BPM keine grafische Unterstützung, keine API, keine Extensions.

Schlimmer noch, wesentliche Eigenschaften, wie die caseDefinitionId sind im Camunda MyBatis-Mapping gar nicht änderbar:

<!-- CASE EXECUTION UPDATE -->

<update id="updateCaseExecution" parameterType="org.camunda.bpm.engine.impl.cmmn.entity.runtime.CaseExecutionEntity">
 update ${prefix}ACT_RU_CASE_EXECUTION set
 REV_ = #{revisionNext, jdbcType=INTEGER},
 CURRENT_STATE_ = #{state, jdbcType=INTEGER},
 PREV_STATE_ = #{previous, jdbcType=INTEGER},
 SUPER_CASE_EXEC_ = #{superCaseExecutionId, jdbcType=VARCHAR},
 SUPER_EXEC_ = #{superExecutionId, jdbcType=VARCHAR}
 where ID_ = #{id, jdbcType=VARCHAR}
 and REV_ = #{revision, jdbcType=INTEGER}
</update>

Selbst ist der Mann, äh, Entwickler!

Also heißt es selbst Hand anlegen. Bernd Rücker, Co-Founder von Camunda, hat in dieser Diskussion schon vorgeschlagen, ein eigenes Command für die Migration zu schreiben und mit einem Custom-MyBatis-Mapping die nötigen Eigenschaften änderbar zu machen. Bei MyBatis fühle ich mich nicht so zu Hause, aber Spring Data JPA habe in meiner Spring-Boot-Applikation sowieso zur Hand. Wenn ich schon selbst Camunda-Tabellen manipulieren muss, warum dann nicht damit? Also flugs die CaseExecution in eine JPA-Entity gemapped und ein Spring Data Repository dazu geschrieben:

@Entity
@Table(name = "ACT_RU_CASE_EXECUTION")
public final class CamundaCaseExecution {

    @Id
    @Column(name = "ID_")
    private String id;

    @Version
    @Column(name = "REV_")
    private Integer revision;

    @Column(name = "CASE_INST_ID_")
    private String caseInstanceId;

    @Column(name = "CASE_DEF_ID_")
    private String caseDefinitionId;

    private CamundaCaseExecution() {} 

    public String getId() {
        return id;
    }

    public void setCaseDefinitionId(String caseDefinitionId) {
        this.caseDefinitionId = caseDefinitionId;
    }
}
 
@Repository
public interface CamundaCaseExecutionRepository extends JpaRepository<CamundaCaseExecution, Integer> {
    List<CamundaCaseExecution> findByCaseInstanceId(String caseInstanceId);
}

Das Gleiche dann noch für die Task, die, falls sie eine HumanTask ist, ebenfalls die caseDefinitionId hält. Zu guter Letzt ein Command drumherum, das die Migration steuert und in der ProcessEngine ausgeführt werden kann. Vorteil der Ausführung in einem Command innerhalb der Engine ist, dass der komplette Kontext zur Verfügung steht und so auch HistoryEvents für die Updates erzeugt werden können.

public class MigrateCaseInstanceVersionCmd implements Command<Void> {

    private String caseDefinitionKey;

    private CaseInstanceMigrator migrator;

    private RepositoryService repositoryService;

    private CaseService caseService;

    private static final Logger LOGGER = LoggerFactory.getLogger(MigrateCaseInstanceVersionCmd.class);

    public MigrateCaseInstanceVersionCmd(final String caseDefinitionKey, final ApplicationContext ctx) {
        Assert.notNull(caseDefinitionKey, "caseDefinitionKey is missing");
        Assert.notNull(ctx, "ApplicationContext is missing");

        this.caseDefinitionKey = caseDefinitionKey;

        migrator = ctx.getBean(CaseInstanceMigrator.class);
        repositoryService = ctx.getBean(RepositoryService.class);
        caseService = ctx.getBean(CaseService.class);
    }

    @Override
    public Void execute(CommandContext commandContext) {

        migrateCasesToLatestVersion(caseDefinitionKey);

        return null;
    }

    private void migrateCasesToLatestVersion(final String caseDefinitionKey) {
        Assert.notNull(caseDefinitionKey, "caseDefinitionKey is missing");

        final String latestCaseDefId = getLatestCaseDefinitionId(caseDefinitionKey);

        migrateAllCaseInstances(latestCaseDefId, caseDefinitionKey);
    }

    private void migrateAllCaseInstances(final String targetCaseDefId, final String caseDefinitionKey) {
        Assert.notNull(targetCaseDefId, "targetCaseDefId is missing");
        Assert.notNull(caseDefinitionKey, "caseDefinitionId is missing");

        final List<String> caseDefinitionIds = ImmutableList.copyOf(getAllButTargetCaseDefinitionIds(caseDefinitionKey, targetCaseDefId));

        final List<CaseInstance> caseInstancesToMigrate = new ArrayList<>();

        caseDefinitionIds.forEach(caseDefinitionId -> caseInstancesToMigrate.addAll(caseService.createCaseInstanceQuery().caseDefinitionId(caseDefinitionId).list()));

        caseInstancesToMigrate.forEach(caseInstance > {
            try {
                migrator.migrateOneCaseInstance(caseInstance.getCaseInstanceId(), targetCaseDefId);
            } catch (Exception e) {
                LOGGER.error(String.format("Exception during migration of case instance '%s", caseInstance.getCaseInstanceId()), e);
            }
        });
    }
 
    ...
}

@Component
public class CaseInstanceMigrator {

    private final CamundaCaseExecutionRepository camundaCaseExecutionRepository;

    private final ProcessEngine processEngine;

    private final CaseService caseService;

    private final TaskMigrator taskMigrator;

    private final List<CaseExecutionMigrationStep> migrationSteps;

    public CaseInstanceMigrator(CamundaCaseExecutionRepository camundaCaseExecutionRepository,
                                ProcessEngine processEngine,
                                CaseService caseService,
                                TaskMigrator taskMigrator,
                                List<CaseExecutionMigrationStep> migrationSteps) {
        this.camundaCaseExecutionRepository = camundaCaseExecutionRepository;
        this.processEngine = processEngine;
        this.caseService = caseService;
        this.taskMigrator = taskMigrator;
        this.migrationSteps = migrationSteps;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void migrateOneCaseInstance(final String caseInstanceId, final String targetCaseDefId) {
        Assert.notNull(caseInstanceId, "caseInstanceId it missing");
        Assert.notNull(targetCaseDefId, "targetCaseDefId is missing");

        final List<CamundaCaseExecution> executionsToMigrate = ImmutableList.copyOf(camundaCaseExecutionRepository.findByCaseInstanceId(caseInstanceId));

        executionsToMigrate.forEach(execution -> migrateOneExecution(execution, targetCaseDefId));

        produceCaseInstanceHistoryEventsForOneCaseInstance(caseInstanceId);

        taskMigrator.migrateAllTasksForCaseInstance(caseInstanceId, targetCaseDefId);
    }

    private void migrateOneExecution(CamundaCaseExecution execution, final String targetCaseDefId) {
        Assert.notNull(execution, "execution is missing");
        Assert.notNull(targetCaseDefId, "targetCaseDefId is missing");

        execution.setCaseDefinitionId(targetCaseDefId);

        final AtomicReference<CamundaCaseExecution> executionRef = new AtomicReference<>(execution);

        migrationSteps.forEach(step -> executionRef.set(step.migrate(executionRef.get())));

        camundaCaseExecutionRepository.save(executionRef.get())
    }

    ...
}

Das Command kann dann z.B. in einen Spring ApplicationEventListener beim Starten der App ausgeführt werden: 

@Component
public class CaseMigrationOnStartup {

    private Boolean migrateOnStartup;

    private final ProcessEngine processEngine;

    private final ApplicationContext ctx;

    private static final Logger LOGGER = LoggerFactory.getLogger(CaseMigrationOnStartup.class);

    public CaseMigrationOnStartup(@Value("${camunda.bpm.migration.case-instance-migration-on-startup:false}") Boolean migrateOnStartup,
                                  ProcessEngine processEngine,
                                  ApplicationContext ctx) {
        this.migrateOnStartup = migrateOnStartup;
        this.processEngine = processEngine;
        this.ctx = ctx;
    }

    @EventListener
    public void migrateOnStartup(ApplicationReadyEvent event) {
        if (migrateOnStartup) {
            ((ProcessEngineConfigurationImpl) processEngine.getProcessEngineConfiguration()).getCommandExecutorTxRequired().execute(new MigrateCaseInstanceVersionCmd("myCaseDefinitionKey", ctx));
        } else if (LOGGER.isInfoEnabled()){
            log.info("CaseInstance migration on application startup is disabled");
        }
    }
}

Auf diese Weise kann zumindest die Minimalanforderung, das simple Erhöhen der Version, erreicht werden. Das funktioniert freilich nur so lange, wie die Änderungen abwärtskompatibel sind. Werden Activities entfernt, neue Variablen nötig oder dergleichen, muss im Rahmen der Migration mehr getan werden und das Schreiben detaillierterer Migrationspläne (z.B. als CaseInstanceMigrationStep) wird nötig.

Und nun?

Der komplette Code für dieses Beispiel liegt in unserem Holunda-Repository auf GitHub.

Wenn Ihr auch findet, dass dieses Feature eigentlich in die Core Engine gehört, votet für den Feature-Request bei Camunda: https://app.camunda.com/jira/browse/CAM-7344

Über den Autor

Ich bin Senior Consultant bei Holisticon und beschäftige mich dort vor allem mit BPM/SOA und Themen rund um Prozessorientierung und -automatisierung. Dabei interessieren mich besonders alle Dinge an der Schnittstelle zwischen Business und IT.

Antwort hinterlassen