All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.occurrent.subscription.blocking.durable.catchup.CatchupSubscriptionModel Maven / Gradle / Ivy

There is a newer version: 0.19.5
Show newest version
/*
 * Copyright 2020 Johan Haleby
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.occurrent.subscription.blocking.durable.catchup;

import io.cloudevents.CloudEvent;
import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy;
import org.occurrent.eventstore.api.blocking.EventStoreQueries;
import org.occurrent.filter.Filter;
import org.occurrent.subscription.*;
import org.occurrent.subscription.StartAt.StartAtSubscriptionPosition;
import org.occurrent.subscription.StartAt.SubscriptionModelContext;
import org.occurrent.subscription.api.blocking.*;
import org.occurrent.subscription.blocking.durable.catchup.SubscriptionPositionStorageConfig.PersistSubscriptionPositionDuringCatchupPhase;
import org.occurrent.subscription.blocking.durable.catchup.SubscriptionPositionStorageConfig.UseSubscriptionPositionInStorage;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Stream;

import static org.occurrent.condition.Condition.gt;
import static org.occurrent.filter.Filter.time;
import static org.occurrent.time.internal.RFC3339.RFC_3339_DATE_TIME_FORMATTER;

/**
 * A {@link SubscriptionModel} that can read historic cloud events from the all event streams (see {@link EventStoreQueries#all()}) until caught up with the
 * {@link PositionAwareSubscriptionModel#globalSubscriptionPosition()} of the {@code subscription} (you probably want to narrow the historic set events of events
 * by using a {@link Filter} when subscribing). It'll automatically switch over to the wrapped {@code subscription model} when all history events are read and the subscription has caught-up.
 * 
Important: The subscription model will only stream historic events if started with a {@link TimeBasedSubscriptionPosition}, by default (i.e. if {@code StartAt.subscriptionModelDefault() is used}), * it'll NOT replay historic events, but instead delegate to the wrapped subscription model. Thus, to start the {@link CatchupSubscriptionModel} and make it replay historic events you can start it like this: *
 * var subscriptionModel = new CatchupSubscriptionModel(..);
 * // All examples below are equivalent:
 * subscriptionModel.subscribeFromBeginningOfTime("subscriptionId", e -> System.out.println("Event: " + e);
 * subscriptionModel.subscribe("subscriptionId", StartAtTime.beginningOfTime(), e -> System.out.println("Event: " + e);
 * subscriptionModel.subscribe("subscriptionId", StartAt.subscriptionPosition(TimeBasedSubscription.beginningOfTime()), e -> System.out.println("Event: " + e);
 * 
*

* If you're using Kotlin you can import the extension functions from {@code org.occurrent.subscription.blocking.durable.catchup.CatchupSubscriptionModelExtensions.kt} and do: *

 * subscriptionModel.subscribe("subscriptionId", StartAt.beginningOfTime()) { e ->
 *      println("Event: $e")
 * }
 * 
* *

* Note that the implementation uses an in-memory cache (default size is {@value #DEFAULT_CACHE_SIZE} but this can be configured using a {@link CatchupSubscriptionModelConfig}) * to reduce the number of duplicate event when switching from historic events to the current cloud event position. It's highly recommended that the application logic is idempotent if the * cache size doesn't cover all duplicate events. *

*
*

* Also note that the if a the subscription crashes during catch-up mode it'll continue where it left-off on restart, given the no specific `StartAt` position is supplied (i.e. if {@code StartAt.subscriptionModelDefault() is used}). * For this to work, the subscription must store the subscription position in a {@link SubscriptionPositionStorage} implementation periodically. It's possible to configure * how often this should happen in the {@link CatchupSubscriptionModelConfig}. *

*/ public class CatchupSubscriptionModel implements SubscriptionModel, DelegatingSubscriptionModel { private static final int DEFAULT_CACHE_SIZE = 100; private final PositionAwareSubscriptionModel subscriptionModel; private final EventStoreQueries eventStoreQueries; private final CatchupSubscriptionModelConfig config; private final ConcurrentMap runningCatchupSubscriptions = new ConcurrentHashMap<>(); private volatile boolean shuttingDown = false; /** * Create a new instance of {@link CatchupSubscriptionModel} the uses a default {@link CatchupSubscriptionModelConfig} with a cache size of * {@value #DEFAULT_CACHE_SIZE} but store the subscription position during the catch-up phase (i.e. if the application crashes or is shutdown during the * catch-up phase then the subscription will start from the beginning on application restart). After the catch-up phase has completed, the {@link PositionAwareSubscriptionModel} * will dictate how often the subscription position is stored. * * @param subscriptionModel The subscription that'll be used to subscribe to new events after catch-up is completed. * @param eventStoreQueries The API that will be used for catch-up */ public CatchupSubscriptionModel(PositionAwareSubscriptionModel subscriptionModel, EventStoreQueries eventStoreQueries) { this(subscriptionModel, eventStoreQueries, new CatchupSubscriptionModelConfig(DEFAULT_CACHE_SIZE)); } /** * Create a new instance of {@link CatchupSubscriptionModel} the uses the supplied {@link CatchupSubscriptionModelConfig}. * After catch-up mode has completed, the {@link PositionAwareSubscriptionModel} will dictate how often the subscription position is stored. * * @param subscriptionModel The subscription that'll be used to subscribe to new events after catch-up is completed. * @param eventStoreQueries The API that will be used for catch-up * @param config The configuration to use */ public CatchupSubscriptionModel(PositionAwareSubscriptionModel subscriptionModel, EventStoreQueries eventStoreQueries, CatchupSubscriptionModelConfig config) { this.subscriptionModel = subscriptionModel; this.eventStoreQueries = eventStoreQueries; this.config = config; } /** * Shortcut to start subscribing to events matching the supplied filter from begging of time. Same as doing: * *
     * subscriptionModel.subscribe(<subscriptionId>, <filter>, StartAtTime.beginningOfTime(), <action>);
     * 
*/ public Subscription subscribeFromBeginningOfTime(String subscriptionId, SubscriptionFilter filter, Consumer action) { return subscribe(subscriptionId, filter, StartAtTime.beginningOfTime(), action); } /** * Shortcut to start subscribing to all events from begging of time. Same as doing: * *
     * subscriptionModel.subscribe(<subscriptionId>, StartAtTime.beginningOfTime(), <action>);
     * 
*/ public Subscription subscribeFromBeginningOfTime(String subscriptionId, Consumer action) { return subscribe(subscriptionId, StartAtTime.beginningOfTime(), action); } @Override public Subscription subscribe(String subscriptionId, SubscriptionFilter filter, StartAt startAt, Consumer action) { Objects.requireNonNull(startAt, "Start at supplier cannot be null"); if (filter != null && !(filter instanceof OccurrentSubscriptionFilter)) { throw new IllegalArgumentException("Unsupported!"); } final StartAt firstStartAt; if (startAt.isDefault()) { // By default, we check if there's a subscription position stored for this subscription, if so we resume from there, otherwise, // delegate to the parent subscription model. SubscriptionPosition subscriptionPosition = returnIfSubscriptionPositionStorageConfigIs(UseSubscriptionPositionInStorage.class, cfg -> cfg.storage().read(subscriptionId)).orElse(null); if (subscriptionPosition == null) { return getDelegatedSubscriptionModel().subscribe(subscriptionId, filter, startAt, action); } else { firstStartAt = StartAt.subscriptionPosition(subscriptionPosition); } } else if (startAt.isDynamic()) { StartAt startAtGeneratedByDynamic = startAt.get(generateSubscriptionModelContext()); if (startAtGeneratedByDynamic == null) { // We're not allowed to start this subscription model, defer to parent! return getDelegatedSubscriptionModel().subscribe(subscriptionId, filter, startAt, action); } else { firstStartAt = startAtGeneratedByDynamic; } } else { firstStartAt = startAt; } // We want to continue from the wrapping subscription if it has something stored in its position storage. if (!isTimeBasedSubscriptionPosition(firstStartAt)) { return subscriptionModel.subscribe(subscriptionId, filter, firstStartAt, action); } Future subscriptionCompletableFuture = CompletableFuture.supplyAsync(() -> startCatchupSubscription(subscriptionId, filter, startAt, action, firstStartAt)); return new CatchupSubscription(subscriptionId, subscriptionCompletableFuture); } private Subscription startCatchupSubscription(String subscriptionId, SubscriptionFilter filter, StartAt startAt, Consumer action, StartAt firstStartAt) { runningCatchupSubscriptions.put(subscriptionId, true); SubscriptionPosition subscriptionPosition = ((StartAtSubscriptionPosition) firstStartAt.get(generateSubscriptionModelContext())).subscriptionPosition; Filter catchupFilter = deriveFilterToUseDuringCatchupPhase(filter, subscriptionPosition); long numberOfEventsBeforeStartingCatchupSubscription = eventStoreQueries.count(catchupFilter); // Perform the catchup runCatchupForStream(eventStoreQueries.query(catchupFilter, config.catchupPhaseSortBy), subscriptionId, action, null); // Here we check if the delegated subscription model is allowed to execute. The reason for doing this is that // in certain scenarios, such as when using the @Subscription annotation with settings {@code startAt=BEGINNING_OF_TIME} and // {@code resume=SAME_AS_START_AT}, we instruct the DurableSubscriptionModel (which is typically the delegated subscription model here) // to NOT store the position durably. This because we start at "beginning of time" and we also want to resume at // "beginning of time" and thus we never need to store ANY subscription position (because we always start from "beginning of time" // when application is rebooted). This allows for catching up in-memory projections/views/policies. Class delegatedSubscriptionModelType = getDelegatedSubscriptionModel().getClass(); StartAt delegatedStartAt = startAt.get(new SubscriptionModelContext(delegatedSubscriptionModelType)); final SubscriptionPosition globalSubscriptionPosition; if (delegatedStartAt == null) { // The delegated subscription model is not allowed to subscribe, so we don't need to get the global position. globalSubscriptionPosition = null; } else { // Here's the reason why we're forcing the wrapping subscription to be a PositionAwareBlockingSubscription. // This is in order to be 100% safe since we need to take events that are published meanwhile the EventStoreQuery // is executed. Thus, we need the global position of the subscription at the time of starting the query. globalSubscriptionPosition = subscriptionModel.globalSubscriptionPosition(); } // Here we check if new events have arrived during catchup phase, if so we stream/catch-up these events as well. long numberOfEventsAfterCatchupSubscriptionCompleted = eventStoreQueries.count(catchupFilter); long numberOfEventsNotConsumed = numberOfEventsAfterCatchupSubscriptionCompleted - numberOfEventsBeforeStartingCatchupSubscription; // We generate a cache so that events that are streamed at the same time as streaming the events missed // during the catch-up phase are not streamed again. FixedSizeCache catchupPhaseCache = new FixedSizeCache(config.cacheSize); if (numberOfEventsNotConsumed > 0) { var cloudEvents = eventStoreQueries.query(catchupFilter, Math.toIntExact(numberOfEventsBeforeStartingCatchupSubscription), Math.toIntExact(numberOfEventsNotConsumed), config.catchupPhaseSortBy); runCatchupForStream(cloudEvents, subscriptionId, action, catchupPhaseCache); } // We check if the delegated subscription model is not allowed to subscribe. If so, we remove any temporary subscription position written during the catchup phase // since we're now done with the catch-up. if (delegatedStartAt == null) { returnIfSubscriptionPositionStorageConfigIs(UseSubscriptionPositionInStorage.class, cfg -> { cfg.storage().delete(subscriptionId); return null; }); } final boolean subscriptionsWasCancelledOrShutdown; if (!shuttingDown && runningCatchupSubscriptions.containsKey(subscriptionId)) { subscriptionsWasCancelledOrShutdown = false; runningCatchupSubscriptions.remove(subscriptionId); } else { // When runningCatchupSubscriptions doesn't contain the key at this stage it means that it has been explicitly cancelled. subscriptionsWasCancelledOrShutdown = true; } // When the catch-up subscription is ready, we store the global position in the position storage so that subscriptions // that have not received _any_ new events during replay will start at the global position if the application is restarted. // Otherwise, nothing will be stored in the "storage" and replay of historic events will take place again on application restart // which is not what we want! The reason for doing this with UseSubscriptionPositionInStorage (as opposed to just // PersistSubscriptionPositionDuringCatchupPhase) is that if using a "storage" at all in the config, is to accommodate // that the wrapping subscription continues from where we left off. StartAt startAtToUse = StartAt.dynamic(this., UseSubscriptionPositionInStorage>returnIfSubscriptionPositionStorageConfigIs(UseSubscriptionPositionInStorage.class, cfg -> () -> { // It's important that we find the document inside the supplier so that we look up the latest resume token on retry SubscriptionPosition position = cfg.storage().read(subscriptionId); // If there is no position stored in storage, or if the stored position is time-based // (i.e. written by the catch-up subscription), we save the globalSubscriptionPosition. // The reason that we need to write the time-based subscription position in this case // is that the wrapped subscription might not support time-based subscriptions. if ((position == null || isTimeBasedSubscriptionPosition(position)) && globalSubscriptionPosition != null) { position = cfg.storage().save(subscriptionId, globalSubscriptionPosition); } else if (position == null) { // Position can still be null here if globalSubscriptionPosition is null, if so, we start at the "subscriptionModelDefault", // given that the delegated subscription model is allowed to subscribe (i.e. delegatedStartAt != null). return delegatedStartAt == null ? startAt : StartAt.subscriptionModelDefault(); } return StartAt.subscriptionPosition(position); }) .orElse(() -> { if (globalSubscriptionPosition == null) { // We check if the delegated subscription model is allowed to subscribe (delegatedStartAt != null), // if so we instruct the subscription model to start from default, otherwise just return the original // startAt supplied by the user. return delegatedStartAt == null ? startAt : StartAt.subscriptionModelDefault(); } else { return StartAt.subscriptionPosition(globalSubscriptionPosition); } })); return startDelegatedSubscription(subscriptionId, filter, action, subscriptionsWasCancelledOrShutdown, startAtToUse, catchupPhaseCache); } private Subscription startDelegatedSubscription(String subscriptionId, SubscriptionFilter filter, Consumer action, boolean subscriptionsWasCancelledOrShutdown, StartAt startAtToUse, FixedSizeCache catchupPhaseEventCache) { final Subscription subscription; if (subscriptionsWasCancelledOrShutdown) { doIfSubscriptionPositionStorageConfigIs(UseSubscriptionPositionInStorage.class, cfg -> { // Only get position if using storage and no position has been stored! if (!cfg.storage().exists(subscriptionId)) { startAtToUse.get(generateSubscriptionModelContext()); } }); subscription = new CancelledSubscription(subscriptionId); } else { subscription = getDelegatedSubscriptionModel().subscribe(subscriptionId, filter, startAtToUse, cloudEvent -> { if (!catchupPhaseEventCache.isCached(cloudEvent.getId())) { action.accept(cloudEvent); } }); } return subscription; } private static Filter deriveFilterToUseDuringCatchupPhase(SubscriptionFilter filter, SubscriptionPosition subscriptionPosition) { final Filter timeFilter; if (isBeginningOfTime(subscriptionPosition)) { timeFilter = Filter.all(); } else { OffsetDateTime offsetDateTime = OffsetDateTime.parse(subscriptionPosition.asString(), RFC_3339_DATE_TIME_FORMATTER); timeFilter = time(gt(offsetDateTime)); } final Filter catchupFilter; if (filter == null) { catchupFilter = timeFilter; } else { Filter userSuppliedFilter = ((OccurrentSubscriptionFilter) filter).filter; catchupFilter = timeFilter.and(userSuppliedFilter); } return catchupFilter; } private void runCatchupForStream(Stream cloudEvents, String subscriptionId, Consumer action, @Nullable FixedSizeCache cache) { Stream takeWhile = cloudEvents.takeWhile(__ -> !shuttingDown && runningCatchupSubscriptions.containsKey(subscriptionId)); if (cache != null) { takeWhile = takeWhile.peek(e -> cache.put(e.getId())); } takeWhile .peek(action) .filter(returnIfSubscriptionPositionStorageConfigIs(PersistSubscriptionPositionDuringCatchupPhase.class, PersistSubscriptionPositionDuringCatchupPhase::persistCloudEventPositionPredicate).orElse(__ -> false)) .forEach(e -> doIfSubscriptionPositionStorageConfigIs(PersistSubscriptionPositionDuringCatchupPhase.class, cfg -> cfg.storage().save(subscriptionId, TimeBasedSubscriptionPosition.from(e.getTime())))); } private static SubscriptionModelContext generateSubscriptionModelContext() { return new SubscriptionModelContext(CatchupSubscriptionModel.class); } @Override public void stop() { getDelegatedSubscriptionModel().stop(); } @Override public void start(boolean resumeSubscriptionsAutomatically) { getDelegatedSubscriptionModel().start(resumeSubscriptionsAutomatically); } @Override public boolean isRunning() { return getDelegatedSubscriptionModel().isRunning(); } @Override public boolean isRunning(String subscriptionId) { return getDelegatedSubscriptionModel().isRunning(); } @Override public boolean isPaused(String subscriptionId) { return getDelegatedSubscriptionModel().isPaused(subscriptionId); } @Override public Subscription resumeSubscription(String subscriptionId) { return getDelegatedSubscriptionModel().resumeSubscription(subscriptionId); } @Override public void pauseSubscription(String subscriptionId) { getDelegatedSubscriptionModel().pauseSubscription(subscriptionId); } @Override public void cancelSubscription(String subscriptionId) { runningCatchupSubscriptions.remove(subscriptionId); subscriptionModel.cancelSubscription(subscriptionId); doIfSubscriptionPositionStorageConfigIs(UseSubscriptionPositionInStorage.class, cfg -> cfg.storage().delete(subscriptionId)); } @PreDestroy @Override public void shutdown() { shuttingDown = true; runningCatchupSubscriptions.clear(); subscriptionModel.shutdown(); } public static boolean isTimeBasedSubscriptionPosition(StartAt startAt) { StartAt start = startAt.get(generateSubscriptionModelContext()); if (!(start instanceof StartAtSubscriptionPosition)) { return false; } SubscriptionPosition subscriptionPosition = ((StartAtSubscriptionPosition) start).subscriptionPosition; return isTimeBasedSubscriptionPosition(subscriptionPosition); } public static boolean isTimeBasedSubscriptionPosition(SubscriptionPosition subscriptionPosition) { return subscriptionPosition instanceof TimeBasedSubscriptionPosition || (subscriptionPosition instanceof StringBasedSubscriptionPosition && isRfc3339Timestamp(subscriptionPosition.asString())); } private static boolean isRfc3339Timestamp(String string) { try { OffsetDateTime.parse(string, RFC_3339_DATE_TIME_FORMATTER); return true; } catch (Exception exception) { return false; } } private static boolean isBeginningOfTime(SubscriptionPosition subscriptionPosition) { return subscriptionPosition instanceof TimeBasedSubscriptionPosition && ((TimeBasedSubscriptionPosition) subscriptionPosition).isBeginningOfTime(); } @Override public SubscriptionModel getDelegatedSubscriptionModel() { return subscriptionModel; } private static class FixedSizeCache { private final LinkedHashMap cacheContent; FixedSizeCache(int size) { cacheContent = new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return this.size() > size; } }; } private void put(String value) { cacheContent.put(value, null); } public boolean isCached(String key) { return cacheContent.containsKey(key); } } private Optional returnIfSubscriptionPositionStorageConfigIs(Class cls, Function fn) { if (cls.isInstance(config.subscriptionStorageConfig)) { return Optional.ofNullable(fn.apply(cls.cast(config.subscriptionStorageConfig))); } return Optional.empty(); } private void doIfSubscriptionPositionStorageConfigIs(Class cls, Consumer consumer) { if (cls.isInstance(config.subscriptionStorageConfig)) { consumer.accept(cls.cast(config.subscriptionStorageConfig)); } } private record CancelledSubscription(String subscriptionId) implements Subscription { @Override public String id() { return subscriptionId; } @Override public boolean waitUntilStarted(Duration timeout) { return true; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy