Oskar Dudycz

Pragmatycznie o programowaniu

Persistent vs catch-up, EventStoreDB subscriptions in action

2022-05-04 oskar dudyczEvent Sourcing

cover

Events can be a great facilitator and glue for business workflows. Subscriptions are an essential block of the event-driven system. They notify us about each of the recorded events. We can trigger the following steps and ensure they’re always processed based on the recorded events. I wrote already about that aspect in Marten, today I’d like to focus on EventStoreDB.

EventStoreDB has two types of subscriptions:

They may look similar, but their use cases are much different. Persistent subscriptions look tempting. They have a more straightforward API, as it seems that they handle all internally: scaling consumers, handling retries and knowing which event was processed the last time. All of that looks fabulous, right? Holy grail? Well, almost.

Persistent subscriptions implement the Competing Consumers Pattern. Persistent subscriptions work on a consumer group basis. Do you know how Kafka consumer groups work? Yes? Then EventStoreDB consumer groups are much different. Kafka guarantees that only a single consumer will get an event from a specific partition for the consumer group. You have an ordering guarantee for the events inside a partition, thanks to that.

That, of course, has a downside; Kafka cannot distribute the load for the specific partition. Like, e.g. RabbitMQ is doing. If you have multiple consumers of the particular queue, then RabbitMQ will try immediately distributing messages between them. Together with retries, that may lead to race conditions and out of order processing. For instance, when one consumer is slower than the other, message processing fails with a transient error and has to be processed again. The same applies to EventStoreDB. It will do its best to distribute events to respect events order; however, it will always be just the best effort. Different consuming strategies can help, but they will change the scale of the issue. Thus, persistent subscriptions work great if you need broadcast. So you’re notifying multiple consumers that something has happened, but the order is not critical.

Where processing order matters, you should use catch-up subscriptions. They respect the order but are raw compared to persistent subscriptions. They don’t have built-in retries, spreading the load etc. At first glance, that may look bad, but that gives more flexibility and options to fine-tune your use case.

You also need to maintain the last processed event position. That means loading and storing checkpoints. The typical flow for catch-up subscriptions looks as follows:

  1. Load the last processed event position.
  2. If it exists, subscribe to the next event. If it does not, subscribe from the first event in the stream.
  3. Get a notification about the event and process it.
  4. Store position of that event.

Thanks to that, when service is down or something goes wrong, you don’t have to start from the beginning, but you know the position. You can also reset the position to reprocess events. Read more about positions in my article Let’s talk about positions in event stores.

If you’re storing events in the transactional database, you can store position together with database changes and get exactly-once processing semantics. Fore instance code in TypeScript and NodeJS could look like:

export const getCheckpoints = () => subscriptionCheckpoints(getPostgres());

export const loadCheckPointFromPostgres = async (subscriptionId: string) => {
  const checkpoints = getCheckpoints();

  const checkpoint = await checkpoints.findOne({
    id: subscriptionId,
  });

  return checkpoint != null ? BigInt(checkpoint.position) : undefined;
};

export type PostgresEventHandler = (
  db: Transaction,
  event: SubscriptionResolvedEvent
) => Promise<void>;

const storeCheckpointInPostgres = async (event: SubscriptionResolvedEvent) => {
  const checkpoints = getCheckpoints();

  await checkpoints.insertOrUpdate(['id'], {
    id: event.subscriptionId,
    position: Number(event.commitPosition),
  });
};

export const handleEventInPostgresTransactionScope =
  (handlers: PostgresEventHandler[]) =>
  async (event: SubscriptionResolvedEvent) => {
    await getPostgres().tx(async (transaction) => {
      await transaction.task(async (db) => {
        for (const handle of handlers) {
          await handle(db, event);
        }

        storeCheckpointInPostgres(event);
      });
    });
  };

Yet, sometimes you want to keep things simple. For example, you’re using subscriptions for workflow processing (saga, process manager, etc.). Or you’re using a database like MongoDB that doesn’t allow multi-document types transactions. Then you may just want to store events inside EventStoreDB, not to add more moving pieces. How to do that?

See the example below in C#:

public interface ISubscriptionCheckpointRepository
{
    ValueTask<ulong?> Load(string subscriptionId, CancellationToken ct);

    ValueTask Store(string subscriptionId, ulong position, CancellationToken ct);
}

public record CheckpointStored(string SubscriptionId, ulong? Position, DateTime CheckpointedAt);

public class EventStoreDBSubscriptionCheckpointRepository: ISubscriptionCheckpointRepository
{
    private readonly EventStoreClient eventStoreClient;

    public EventStoreDBSubscriptionCheckpointRepository(
        EventStoreClient eventStoreClient)
    {
        this.eventStoreClient = eventStoreClient ?? throw new ArgumentNullException(nameof(eventStoreClient));
    }

    public async ValueTask<ulong?> Load(string subscriptionId, CancellationToken ct)
    {
        var streamName = GetCheckpointStreamName(subscriptionId);

        var result = eventStoreClient.ReadStreamAsync(Direction.Backwards, streamName, StreamPosition.End, 1,
            cancellationToken: ct);

        if (await result.ReadState == ReadState.StreamNotFound)
        {
            return null;
        }

        ResolvedEvent? @event = await result.FirstOrDefaultAsync(ct);

        return @event?.Deserialize<CheckpointStored>()?.Position;
    }

    public async ValueTask Store(string subscriptionId, ulong position, CancellationToken ct)
    {
        var @event = new CheckpointStored(subscriptionId, position, DateTime.UtcNow);
        var eventToAppend = new[] {@event.ToJsonEventData()};
        var streamName = GetCheckpointStreamName(subscriptionId);

        try
        {
            // store new checkpoint expecting stream to exist
            await eventStoreClient.AppendToStreamAsync(
                streamName,
                StreamState.StreamExists,
                eventToAppend,
                cancellationToken: ct
            );
        }
        catch (WrongExpectedVersionException)
        {
            // WrongExpectedVersionException means that stream did not exist
            // Set the checkpoint stream to have at most 1 event
            // using stream metadata $maxCount property
            await eventStoreClient.SetStreamMetadataAsync(
                streamName,
                StreamState.NoStream,
                new StreamMetadata(1),
                cancellationToken: ct
            );

            // append event again expecting stream to not exist
            await eventStoreClient.AppendToStreamAsync(
                streamName,
                StreamState.NoStream,
                eventToAppend,
                cancellationToken: ct
            );
        }
    }

    private static string GetCheckpointStreamName(string subscriptionId) => $"checkpoint_{subscriptionId}";
}

Are you doing Java? No worries, here’s how it could look like: EventStoreDBSubscriptionCheckpointRepository.java.

This code allows to load and store position in an event stream. Each subscription will get its stream. We need just the last processed position, and there’s no need to keep more than one event. We’re setting $maxCount event data on the stream creation to achieve that. EventStoreDB will ensure that only the latest one event will be kept, and the rest will be truncated (Note: they will be physically removed when the scavenging process is run).

How to do retries? You can wrap the handling code with a retry policy or implement it case by case. You can use library like Polly, or write it as I explained in my other article.

Scaling? That’s a topic on its own. Luckily, I have an article for you How to scale projections in the event-driven systems?.

Cheers!

Oskar

p.s. Ukraine is still under brutal Russian invasion. A lot of Ukrainian people are hurt, without shelter and need help. You can help in various ways, for instance, directly helping refugees, spreading awareness, putting pressure on your local government or companies. You can also support Ukraine by donating e.g. to Red Cross, Ukraine humanitarian organisation or donate Ambulances for Ukraine.

👋 If you found this article helpful and want to get notification about the next one, subscribe to Architecture Weekly.

✉️ Join over 11500 subscribers, get the best resources to boost your skills, and stay updated with Software Architecture trends!

Loading...
Event-Driven by Oskar Dudycz

cover

Through my window, I see the result of good plans but poor execution. Opposite my flat, there is a partially completed construction place. Buildings were supposed to be eye-catching Mediterranean style apartments. Delivery date? Two years ago. Actual? More and more unknown.

Some time ago, I heard that using Event Sourcing makes creating Event-Driven Architecture easier. The arguments were correct, that if we’re already publishing events to trigger business workflows, then at some point, we may want to also store events to not lose information. Agreed. However, I also heard that keeping the state as events will simplify things. We’ll have a source of truth with a record of the system behaviour. This will allow, e.g. to confront the results of the operations with the recorded state. I’d agree with that, with one distinction. It’s easier as long as you already know Event Sourcing.

Many people in the DDD community claim that the essential is to properly break down the system into autonomous parts called bounded contexts. Once we have it, the rest is secondary and will sort itself out. For sure.

Many seasoned programmers speak similarly about new technologies. They claim that they can translate past experience into new technologies. That’s true that by analogy, they can catch the big picture quicker. But isn’t it a bold assumption to say that Win.Forms specialist will learn Angular quickly?

The end result may differ a lot from the initial ideas. I saw the plan of those buildings next to me. Now I can see the effects of the execution. Or actually, the lack.

I believe that we should carefully acknowledge not only the point of view of our authorities but also their seating point. If we want to find out how to form a wall, do we ask an architect or a foreman? An architect may know the theory, but the practice is what we’re looking for. On the other hand, if you want to know where to put the wall, you prefer the architect to do measurements. At least if you don’t want to have the roof falling to your head.

After I had torn a ligament in my knee, I went to two qualified orthopedists. One said I should have surgery and do a reconstruction. The second stated that there is no need for that; rehabilitation should be enough. Guess which one had a specialization in surgery and which in rehabilitation?

People usually give us advice from the point where they’re currently standing. They are entitled to a biased view. An architect who rarely does programming will tend to downplay the value of implementation and tactical patterns. Midlevel developers will focus on technicalities instead of the global system impact. The team manager or consultant will emphasize the importance of soft skills (or esoteric techniques known only to them).

The truth is that we need all of them. The excellent plan will fall on the bad execution. The best execution for the wrong case will be just a waste of time. We should carefully evaluate the advice considering what we need and what an expert can give us.

Therefore, when we’re reading an article, watching a talk, let’s also pay attention to the place where the person is standing. The perspective from there may be much different from where we are right now. That can be good, as it may push us in the right direction. But it may also be misleading, as we accidentally take biases of this person without understanding the tradeoffs. Personally, I prefer to follow not only people from pedestal but also those that are closer to my position. A bit further in the journey, but not too far. That helps me to calibrate my view as those people are more relative to my daily struggles.

Polish historical leader Józef Piłsudzki reportedly used to say: “Right is like an ass, everyone has its own”.

Cheers!

Oskar