Execução Síncrona e Assíncrona
Este é o mecanismo mais importante para entender o comportamento em produção do Kikwiflow: quando o motor
processa vários nós em memória, na mesma chamada, sem tocar o banco entre eles, e quando ele para, persiste e
devolve o controle? A resposta vive inteiramente em io.kikwiflow.execution.ProcessExecutionManager.
:::info Isto não é asyncBefore do BPMN
Se você já trabalhou com Camunda/Activiti, é tentador mapear mentalmente este mecanismo para o atributo
camunda:asyncBefore do BPMN XML. A analogia é útil como ponto de partida, mas a implementação é
propositalmente diferente e mais simples: não existe um barramento de mensagens, não existe um job executor
genérico consumindo uma fila — existe uma fila em memória (ExecutionFrame) processada dentro de um único
método, e a fronteira de parada é decidida por dois booleanos simples no próprio nó: commitBefore e
commitAfter.
:::
A "Agenda": fila de ExecutionFrame
public record ExecutionFrame(FlowNodeDefinition node, String branchId, String joinTaskId) {}
ProcessExecutionManager.executeFlow roda um while (!agenda.isEmpty()) sobre uma Queue<ExecutionFrame>.
Cada iteração processa um nó, decide sua continuação via Navigator, e ou:
- Empilha o(s) próximo(s) nó(s) na mesma fila (execução síncrona, tudo na mesma chamada de método, mesma transação lógica em memória), ou
- Retorna imediatamente um
ExecutionResultcom umaContinuationmarcadaisAsynchronous = true, devolvendo o controle para o chamador (KikwiflowEngine), que por sua vez delega a persistência aoContinuationService.
Esse desenho — uma fila local em vez de um loop recursivo linear — existe especificamente para suportar
ramificação paralela: quando um PARALLEL_GATEWAY produz múltiplos próximos nós, cada um vira um
ExecutionFrame independente carregando seu próprio branchId, sem que os ramos precisem compartilhar pilha de
chamada ou se preocupar com condições de corrida em memória — a fila é sempre processada por uma única thread,
por uma única instância de processo, por vez.
commitBefore e commitAfter: as duas fronteiras
Todo FlowNodeDefinition carrega Boolean commitBefore() e Boolean commitAfter(). Seu efeito:
commitBefore: true— o motor para antes de executar aquele nó e persiste o estado atual. Use isso para pontos onde você quer garantir durabilidade antes de uma operação potencialmente cara/lenta/instável (ex.: uma tarefa que chama uma API externa). A tarefa fica agendada (ExecutableTaskcomstatus: PENDINGedueDate: now) para ser pega peloTaskAcquirerem um ciclo de polling separado, possivelmente em outra instância/nó do cluster.commitAfter: true— o motor executa o nó normalmente, mas força a continuação a ser assíncrona, mesmo que o próximo nó não tenhacommitBefore. Use isso quando o próprio nó atual é a operação sensível e você quer garantir que o efeito dele já esteja durável antes do motor seguir adiante.
final boolean shouldStopForCommitBefore = isCommitBefore(currentNode)
&& !(isFirstNodeInLoop && isResumingFromAsyncBefore);
if (isWaitState(currentNode) || shouldStopForCommitBefore) {
return new ExecutionResult(
new ExecutionOutcome(processInstance, criticalEvents),
new Continuation(List.of(currentNode), true));
}
Note a condição !(isFirstNodeInLoop && isResumingFromAsyncBefore): quando o TaskAcquirer retoma a execução
de um nó que já parou por commitBefore anteriormente, o motor não deve parar de novo no mesmo nó — ele
executa a lógica de fato dessa vez. Esse flag distingue "estou chegando neste nó pela primeira vez" de "estou
retomando este nó que já foi persistido como pendente".
Todo nó que implementa a marca WaitState (hoje, apenas ExternalTaskDefinition) sempre interrompe a
execução, independentemente de commitBefore — não faz sentido continuar em memória além de um nó que por
definição aguarda um evento externo.
Sem commitBefore/commitAfter: tudo em uma única passada
Se um processo inteiro é composto apenas por EXECUTABLE_TASKs e gateways sem nenhum commitBefore/
commitAfter: true, o motor processa todo o grafo, do início ao fim, em uma única chamada síncrona —
ProcessStarter.execute() retorna já com o ProcessInstance no status final (COMPLETED), sem nenhuma volta
ao TaskAcquirer. Isso é intencional e é o caminho de menor latência possível: para processos puramente
computacionais/rápidos, forçar uma ida ao banco por nó seria desperdício.
{
"type": "EXECUTABLE_TASK",
"executor": "calculateCustomerRiskTaskHandler",
"commitBefore": false,
"commitAfter": false
}
:::tip Quando marcar commitBefore: true
Regra prática: marque commitBefore: true em qualquer EXECUTABLE_TASK que faça I/O externo (chamada HTTP,
mensageria, banco de terceiros) ou que deva sobreviver a um restart do processo Java entre o passo anterior e
este. Deixe false (o padrão idiomático) para transformações de dados puramente locais que você quer que
aconteçam atomicamente com o resto do fluxo síncrono.
:::
Fan-out e fan-in: PARALLEL_GATEWAY e JOIN_GATEWAY
Quando a continuação de um PARALLEL_GATEWAY é processada pelo ContinuationService, cada ramo ganha um
branchId (UUID) próprio, e um ExecutableTask especial do tipo ExecutableTaskType.JOIN_GATEWAY é criado
antecipadamente, já sabendo quais branchIds precisa aguardar:
ExecutableTask joinTask = ExecutableTask.builder()
.taskDefinitionId(continuation.targetJoinNode().id())
.type(ExecutableTaskType.JOIN_GATEWAY)
.status(ExecutableTaskStatus.AWAITING_BRANCHES)
.pendingBranchIds(branchIds)
.build();
Cada ramo então executa independentemente (potencialmente em threads/momentos diferentes, se cruzar uma
fronteira commitBefore). Quando um ramo termina — seja atingindo um DEFAULT_END_EVENT de ramo ou o próprio
JOIN_GATEWAY de destino — a execução chama
processInstance.registerBranchConclusion(joinTaskId, branchId), que acumula uma BranchPullIntention em
memória. Essa intenção é enviada ao UnitOfWork e, na camada de persistência, remove aquele branchId da
lista pendingBranchIds do JOIN_GATEWAY de forma atômica — quando a lista fica vazia, o repositório libera o
JOIN_GATEWAY para execução (fora do escopo deste documento; ver
Persistência e Consistência Transacional).
if (("DEFAULT_END_EVENT".equals(currentNode.type()) || "JOIN_GATEWAY".equals(currentNode.type()))
&& !isLegitimateJoinResumption && currentBranchId != null) {
if (currentJoinTaskId != null) {
processInstance.registerBranchConclusion(currentJoinTaskId, currentBranchId);
}
branchConcluded = true;
continue;
}
Esse design evita a necessidade de locks pessimistas ou contadores centralizados em memória compartilhada — a
contagem de ramos pendentes vive no próprio documento persistido do JOIN_GATEWAY, e cada ramo, ao concluir,
faz uma operação de remoção idempotente sobre essa lista.
Timers não-interruptivos se reagendam sozinhos
Um caso especial de continuação assíncrona: quando um ExecutableTask do tipo NON_INTERRUPTIVE_TIMER
termina, o ContinuationService não apenas processa a continuação normal — ele também calcula a
próxima ocorrência via TimerDueDateEvaluator.calculateNextSchedule e injeta um novo ExecutableTask já
agendado para o próximo ciclo, com um branchId novo (UUID.randomUUID()), efetivamente criando um laço de
recorrência que independe de qualquer scheduler externo (cron do SO, Quartz, etc.) — o próprio motor é o
scheduler. Ver Timers e Agendamento.