Handling Transaction Timing Issues (Distributed Transactions)

Hi everyone :wave:

I’ve run into a distributed-transaction-challenge in our system that I suspect some of you might have encountered as well in the context of zeebe. I’m reaching out to see if anyone has insights, best practices, or alternative approaches for solving this.

The Challenge

We’re using Zeebe to orchestrate processes across our microservices. During testing, I noticed that when a new process is started, there’s a chance that the first task gets executed by Zeebe before the originating transaction has fully committed. This issue applies not only to the process start but also to all cases in which a message is sent to Zeebe.

This can lead to inconsistencies: Zeebe starts the process and executes a task before the database changes are available, causing potential errors when a second worker tries to access uncommitted data.

To clarify, assume the following example, which is also visually represented in the accompanying image. Imagine we have a process that manages vacation requests. This process is deployed to and orchestrated by Zeebe. It includes a start event and an initial worker.

The process is triggered whenever a user submits a vacation request. This is implemented by a CreateVacationRequestService that saves the vacation request to the database and then calls Zeebe to start the process. The service looks something like this and is annotated with @Transactional :

@Transactional
class CreateVacationRequestService(
    val repository: VacationRequestRepository,
    val processPort: ProcessPort
) : CreateVacationRequestUseCase {

    override fun createVacationRequest(command: CreateVacationRequestCommand) {
        val request = VacationRequest(command)
        repository.save(request)
        processPort.vacationRequestCreated(request)
    }
}

The issue arises because the transaction completes only after both the database save and the Zeebe call are done. This means Zeebe might start executing the process while the database commit is still pending, which can result in errors if other tasks need to access the newly created vacation request before it’s available. This issue becomes more likely if additional operations are executed after the process call, extending the transaction duration and increasing the likelihood of inconsistency.

Possible Solutions I’ve Considered

  1. Rely on Zeebe’s Retries: Ensure that the Zeebe start-process-call is always the last action in the service to minimize the risk. Then, I could ignore the issue and rely on Zeebe’s built-in retry mechanism. After a few retries, the transaction should be committed, allowing the task to execute successfully. However, with messages, for example, there could still be a risk of data records overwriting each other. Thus, i think this isn’t the best-idea
  2. Zeebe Call After the Transaction: Move the Zeebe API call outside the transactional scope, ensuring the process starts only after the database commit (same applies to messages). However, this approach introduces its own challenges—such as handling errors during the Zeebe call, which could lead to scenarios where a vacation request exists without an associated process instance.

Both options have their challenges, and I haven’t found much discussion about this issue yet. Before committing to one of these solutions, I wanted to ask the community:

  • Is this a common problem others have faced?
  • Are there any best practices or patterns for handling this kind of distributed transaction issue in Zeebe?
  • Is there a better approach that I may have overlooked?

Looking Forward to Your Insights

I’d really appreciate any feedback, especially from those who have dealt with similar transactional issues in Zeebe. Perhaps there’s an established best practice I missed, or a simpler solution that makes more sense in this context.

Thanks a lot in advance for your help!

Cheers, Marco

Hi,

Youre dealing with a classic distributed transaction issue… There are a few established patterns in this space, see saga, outbox to name a few…

I would not advocate option 1. This is ultimately a race condition which can always fail…

Im more likely to go with option 2 with some further considerations…

From a UX perspective, both operations need to succeed, thus if either fail you need to throw back to the user with an error and please try again …

However with this approach, you could end up with orphaned requests in the db. To address this I would put the process instance key in as an attribute in the db request so you know which requests are owned. You can then uses a periodic reaper process to purge orphaned requests…

The above is a simplistic approach, but is forward compatible to transition to outbox pattern if required…

Regards

Rob

1 Like

Hi @emaarco,

in my point of view, you have a similar issue with the worker too.
Consider the following diagram:


If something goes wrong with completing the job, you will have to deal with it.

You have the same challenge with systems like Kafka. So, how do you handle this? You cannot put the out port in the same transaction. Things can also go wrong when committing the DB transaction, e.g. optimistic locking. You then cannot rollback the remote changes. As you have described well, it also leads to problems that the outgoing events / commands are already processed before the state is actually committed.

I’m actually not so sure about the solution here. If we suppose that the engine is a highly available system, we can assume that the commands are accepted by the engine for Process Start and Send Message. Therefore, in my opinion, you can skip the outbox pattern.

If you also already have a system like Kafka in use, you could also send commands (after transaction) to yourself and process them asynchronously. But what would be the difference between sending it to the engine straight away? Any external system that you call after the transaction carries the risk that the action will fail. This also applies to a database in which the outbox pattern is mapped.

Thank you, @Webcyberrob, for calling out the issue directly and mentioning established patterns like Saga and Outbox. I really appreciate your recommendation! :slight_smile:

@dominikh, I agree with your point. The problem applies to all kinds of interactions with Zeebe, as well as with external systems in general.

When working with Kafka, we’ve addressed similar issues by publishing messages to the internal event bus during the transaction and ensuring they are actually sent after the transaction using mechanisms like TransactionalEventListeners. This is a simple solution, but it also comes with a trade-off. Because, as you pointed out, this still leaves room for potential inconsistencies—such as errors that might prevent the message from being published after the transaction. To sum it up: It’s a challenge that isn’t fully resolved even in these scenarios - but it’s a trade-off we have consciously accepted for now, with the intention of fully solving it later.

All in all I wanted to simplify my example by focusing specifically on explicit interactions with the Zeebe API, such as starting processes or sending messages to the engine. Nevertheless, I acknowledge that this broader issue extends across many integration points (like f.ex. job-workers).

Ultimately, I am searching for a lightweight solution—one that avoids the overhead of managing dedicated database tables or complex synchronization mechanisms. Ideally, I want to keep things simple while still ensuring reliable integration.

If you want a fully reliable solution, consider storing the outbox entry within your transaction. However, I’m not a fan of the added complexity and would love to find a simpler approach. This is probably not necessary for jobs. It may be necessary for messages and process starts.

Thank you for your response @dominikh. My concerns are not just about the added complexity—it’s also about the time-critical nature of my processes.

I rely on these processes starting in near real-time, with the first tasks being available almost immediately. Often, the first task is a user task, and it’s crucial that the user doesn’t experience a significant delay. The user initiates the process and expects to proceed with the next task promptly, so responsiveness is key to maintaining a positive user experience.

Regarding the outbox pattern, I have some thoughts based on this conversation and your suggestion (maybe they will help us find a proper solution):

If we introduce a scheduler that periodically reads data from an outbox table (potentially in batches), it inherently introduces a delay before the follow-up task becomes available. This delay could be even longer if errors occur during processing, meaning a message is not sent in the first scheduler run.

An alternative could be using a transactional event listener (or something similar) to extract the record from the outbox table immediately after the transaction completes and then send the corresponding message to Zeebe. This approach would achieve near real-time execution, unless a failure occurs, in which case retry logic would be required to ensure reliability. However, using a synchronous event listener could extend the response time significantly, potentially blocking the user’s request to start the process, which is unacceptable. Thus, we would need to execute the logic asynchronously or limit the number of retries. The latter, however, introduces the risk that the process may never be created, necessitating a second mechanism, such as a scheduler, to handle messages that couldn’t be sent initially.

Ultimately, we reach the same conclusion: solving this issue results in either increased complexity or an accepted risk of inconsistency in the system. I’m still trying to find a balance that ensures satisfying reliability without compromising simplicity or the responsiveness that users expect. I hope, we can find a good solution together :rocket:.