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

com.gruelbox.transactionoutbox.TransactionOutbox Maven / Gradle / Ivy

package com.gruelbox.transactionoutbox;

import static com.gruelbox.transactionoutbox.Utils.logAtLevel;
import static com.gruelbox.transactionoutbox.Utils.uncheckedly;
import static java.time.temporal.ChronoUnit.MILLIS;
import static java.time.temporal.ChronoUnit.MINUTES;

import java.lang.reflect.InvocationTargetException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executor;
import javax.validation.ClockProvider;
import javax.validation.Valid;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.internal.engine.DefaultClockProvider;
import org.slf4j.MDC;
import org.slf4j.event.Level;

/**
 * An implementation of the Transactional Outbox
 * pattern for Java. See README for
 * usage instructions.
 */
@Slf4j
public class TransactionOutbox {

  private static final int DEFAULT_FLUSH_BATCH_SIZE = 4096;

  @NotNull private final TransactionManager transactionManager;
  @Valid @NotNull private final Persistor persistor;
  @Valid @NotNull private final Instantiator instantiator;
  @NotNull private final Submitter submitter;
  @NotNull private final Duration attemptFrequency;
  @NotNull private final Level logLevelTemporaryFailure;

  @Min(1)
  private final int blacklistAfterAttempts;

  @Min(1)
  private final int flushBatchSize;

  @NotNull private final ClockProvider clockProvider;
  @NotNull private final TransactionOutboxListener listener;
  private final boolean serializeMdc;
  private final Validator validator;
  @NotNull private final Duration retentionThreshold;

  private TransactionOutbox(
      TransactionManager transactionManager,
      Instantiator instantiator,
      Submitter submitter,
      Duration attemptFrequency,
      int blacklistAfterAttempts,
      int flushBatchSize,
      ClockProvider clockProvider,
      TransactionOutboxListener listener,
      Persistor persistor,
      Level logLevelTemporaryFailure,
      Boolean serializeMdc,
      Duration retentionThreshold) {
    this.transactionManager = transactionManager;
    this.instantiator = Utils.firstNonNull(instantiator, Instantiator::usingReflection);
    this.persistor = persistor;
    this.submitter = Utils.firstNonNull(submitter, Submitter::withDefaultExecutor);
    this.attemptFrequency = Utils.firstNonNull(attemptFrequency, () -> Duration.of(2, MINUTES));
    this.blacklistAfterAttempts = blacklistAfterAttempts <= 1 ? 5 : blacklistAfterAttempts;
    this.flushBatchSize = flushBatchSize <= 1 ? DEFAULT_FLUSH_BATCH_SIZE : flushBatchSize;
    this.clockProvider = Utils.firstNonNull(clockProvider, () -> DefaultClockProvider.INSTANCE);
    this.listener = Utils.firstNonNull(listener, () -> new TransactionOutboxListener() {});
    this.logLevelTemporaryFailure = Utils.firstNonNull(logLevelTemporaryFailure, () -> Level.WARN);
    this.validator = new Validator(this.clockProvider);
    this.serializeMdc = serializeMdc == null ? true : serializeMdc;
    this.retentionThreshold = retentionThreshold == null ? Duration.ofDays(7) : retentionThreshold;
    this.validator.validate(this);
    this.persistor.migrate(transactionManager);
  }

  /** @return A builder for creating a new instance of {@link TransactionOutbox}. */
  public static TransactionOutboxBuilder builder() {
    return new TransactionOutboxBuilder();
  }

  /**
   * The main entry point for submitting new transaction outbox tasks.
   *
   * 

Returns a proxy of {@code T} which, when called, will instantly return and schedule a call * of the real method to occur after the current transaction is committed (as such a * transaction needs to be active and accessible from {@link #transactionManager}) * *

Usage: * *

transactionOutbox.schedule(MyService.class)
   *   .runMyMethod("with", "some", "arguments");
* *

This will write a record to the database using the supplied {@link Persistor} and {@link * Instantiator}, using the current database transaction, which will get rolled back if the rest * of the transaction is, and thus never processed. However, if the transaction is committed, the * real method will be called immediately afterwards using the supplied {@link #submitter}. Should * that fail, the call will be reattempted whenever {@link #flush()} is called, provided at least * {@link #attemptFrequency} has passed since the time the task was last attempted. * * @param clazz The class to proxy. * @param The type to proxy. * @return The proxy of {@code T}. */ public T schedule(Class clazz) { return schedule(clazz, null); } /** * Starts building a schedule request with parameterization. See {@link * ParameterizedScheduleBuilder#schedule(Class)} for more information. * * @return Builder. */ public ParameterizedScheduleBuilder with() { return new ParameterizedScheduleBuilderImpl(); } /** * Identifies any stale tasks queued using {@link #schedule(Class)} (those which were queued more * than {@link #attemptFrequency} ago and have been tried less than {@link * #blacklistAfterAttempts} times) and attempts to resubmit them. * *

As long as {@link #submitter} is non-blocking (e.g. uses a bounded queue with a {@link * java.util.concurrent.RejectedExecutionHandler} which throws such as {@link * java.util.concurrent.ThreadPoolExecutor.AbortPolicy}), this method will return quickly. * However, if {@link #submitter} uses a bounded queue with a blocking policy, this method could * block for a long time, depending on how long the scheduled work takes and how large {@link * #flushBatchSize} is. * *

Calls {@link TransactionManager#inTransactionReturns(TransactionalSupplier)} to start a new * transaction for the fetch. * *

Additionally, expires any records completed prior to the {@link * TransactionOutboxBuilder#retentionThreshold(Duration)}. * * @return true if any work was flushed. */ @SuppressWarnings("UnusedReturnValue") public boolean flush() { Instant now = clockProvider.getClock().instant(); List batch = flush(now); expireIdempotencyProtection(now); return !batch.isEmpty(); } private List flush(Instant now) { log.debug("Flushing stale tasks"); var batch = transactionManager.inTransactionReturns( transaction -> { List result = new ArrayList<>(flushBatchSize); uncheckedly(() -> persistor.selectBatch(transaction, flushBatchSize, now)) .forEach( entry -> { log.debug("Reprocessing {}", entry.description()); try { pushBack(transaction, entry); result.add(entry); } catch (OptimisticLockException e) { log.debug("Beaten to optimistic lock on {}", entry.description()); } }); return result; }); log.debug("Got batch of {}", batch.size()); batch.forEach(this::submitNow); log.debug("Submitted batch"); return batch; } private void expireIdempotencyProtection(Instant now) { long totalRecordsDeleted = 0; int recordsDeleted; do { recordsDeleted = transactionManager.inTransactionReturns( tx -> uncheckedly(() -> persistor.deleteProcessedAndExpired(tx, flushBatchSize, now))); totalRecordsDeleted += recordsDeleted; } while (recordsDeleted > 0); if (totalRecordsDeleted > 0) { long s = retentionThreshold.toSeconds(); String duration = String.format("%dd:%02dh:%02dm", s / 3600, (s % 3600) / 60, (s % 60)); log.info( "Expired idempotency protection on {} requests completed more than {} ago", totalRecordsDeleted, duration); } else { log.debug("No records found to delete as of {}", now); } } /** * Marks a blacklisted entry back to not blacklisted and resets the attempt count. Requires an * active transaction and a transaction manager that supports thread local context. * * @param entryId The entry id. * @return True if the whitelisting request was successful. May return false if another thread * whitelisted the entry first. */ public boolean whitelist(String entryId) { if (!(transactionManager instanceof ThreadLocalContextTransactionManager)) { throw new UnsupportedOperationException( "This method requires a ThreadLocalContextTransactionManager"); } log.info("Whitelisting entry {}", entryId); try { return ((ThreadLocalContextTransactionManager) transactionManager) .requireTransactionReturns(tx -> persistor.whitelist(tx, entryId)); } catch (Exception e) { throw (RuntimeException) Utils.uncheckAndThrow(e); } } /** * Marks a blacklisted entry back to not blacklisted and resets the attempt count. Requires an * active transaction and a transaction manager that supports supplied context. * * @param entryId The entry id. * @param transactionContext The transaction context ({@link TransactionManager} implementation * specific). * @return True if the whitelisting request was successful. May return false if another thread * whitelisted the entry first. */ @SuppressWarnings({"unchecked", "rawtypes"}) public boolean whitelist(String entryId, Object transactionContext) { if (!(transactionManager instanceof ParameterContextTransactionManager)) { throw new UnsupportedOperationException( "This method requires a ParameterContextTransactionManager"); } log.info("Whitelisting entry {}", entryId); try { if (transactionContext instanceof Transaction) { return persistor.whitelist((Transaction) transactionContext, entryId); } Transaction transaction = ((ParameterContextTransactionManager) transactionManager) .transactionFromContext(transactionContext); return persistor.whitelist(transaction, entryId); } catch (Exception e) { throw (RuntimeException) Utils.uncheckAndThrow(e); } } private T schedule(Class clazz, String uniqueRequestId) { return Utils.createProxy( clazz, (method, args) -> uncheckedly( () -> { var extracted = transactionManager.extractTransaction(method, args); TransactionOutboxEntry entry = newEntry( extracted.getClazz(), extracted.getMethodName(), extracted.getParameters(), extracted.getArgs(), uniqueRequestId); validator.validate(entry); persistor.save(extracted.getTransaction(), entry); extracted .getTransaction() .addPostCommitHook( () -> { listener.scheduled(entry); submitNow(entry); }); log.debug( "Scheduled {} for running after transaction commit", entry.description()); return null; })); } private void submitNow(TransactionOutboxEntry entry) { submitter.submit(entry, this::processNow); } /** * Processes an entry immediately in the current thread. Intended for use in custom * implementations of {@link Submitter} and should not generally otherwise be called. * * @param entry The entry. */ @SuppressWarnings("WeakerAccess") public void processNow(TransactionOutboxEntry entry) { try { var success = transactionManager.inTransactionReturnsThrows( transaction -> { if (!persistor.lock(transaction, entry)) { return false; } log.info("Processing {}", entry.description()); invoke(entry, transaction); if (entry.getUniqueRequestId() == null) { persistor.delete(transaction, entry); } else { log.debug( "Deferring deletion of {} by {}", entry.description(), retentionThreshold); entry.setProcessed(true); entry.setNextAttemptTime(after(retentionThreshold)); persistor.update(transaction, entry); } return true; }); if (success) { log.info("Processed {}", entry.description()); listener.success(entry); } else { log.debug("Skipped task {} - may be locked or already processed", entry.getId()); } } catch (InvocationTargetException e) { updateAttemptCount(entry, e.getCause()); } catch (Exception e) { updateAttemptCount(entry, e); } } private void invoke(TransactionOutboxEntry entry, Transaction transaction) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { Object instance = instantiator.getInstance(entry.getInvocation().getClassName()); log.debug("Created instance {}", instance); transactionManager.injectTransaction(entry.getInvocation(), transaction).invoke(instance); } private TransactionOutboxEntry newEntry( Class clazz, String methodName, Class[] params, Object[] args, String uniqueRequestId) { return TransactionOutboxEntry.builder() .id(UUID.randomUUID().toString()) .invocation( new Invocation( instantiator.getName(clazz), methodName, params, args, serializeMdc && (MDC.getMDCAdapter() != null) ? MDC.getCopyOfContextMap() : null)) .nextAttemptTime(after(attemptFrequency)) .uniqueRequestId(uniqueRequestId) .build(); } private void pushBack(Transaction transaction, TransactionOutboxEntry entry) throws OptimisticLockException { try { entry.setNextAttemptTime(after(attemptFrequency)); validator.validate(entry); persistor.update(transaction, entry); } catch (OptimisticLockException e) { throw e; } catch (Exception e) { Utils.uncheckAndThrow(e); } } private Instant after(Duration duration) { return clockProvider.getClock().instant().plus(duration).truncatedTo(MILLIS); } private void updateAttemptCount(TransactionOutboxEntry entry, Throwable cause) { try { entry.setAttempts(entry.getAttempts() + 1); var blacklisted = entry.getAttempts() >= blacklistAfterAttempts; entry.setBlacklisted(blacklisted); entry.setNextAttemptTime(after(attemptFrequency)); validator.validate(entry); transactionManager.inTransactionThrows(transaction -> persistor.update(transaction, entry)); listener.failure(entry, cause); if (blacklisted) { log.error( "Blacklisting failing process after {} attempts: {}", entry.getAttempts(), entry.description(), cause); listener.blacklisted(entry, cause); } else { logAtLevel( log, logLevelTemporaryFailure, "Temporarily failed to process: {}", entry.description(), cause); } } catch (Exception e) { log.error( "Failed to update attempt count for {}. It may be retried more times than expected.", entry.description(), e); } } /** Builder for {@link TransactionOutbox}. */ @ToString public static class TransactionOutboxBuilder { private TransactionManager transactionManager; private Instantiator instantiator; private Submitter submitter; private Duration attemptFrequency; private int blacklistAfterAttempts; private int flushBatchSize; private ClockProvider clockProvider; private TransactionOutboxListener listener; private Persistor persistor; private Level logLevelTemporaryFailure; private Boolean serializeMdc; private Duration retentionThreshold; TransactionOutboxBuilder() {} /** * @param transactionManager Provides {@link TransactionOutbox} with the ability to start, * commit and roll back transactions as well as interact with running transactions started * outside. * @return Builder. */ public TransactionOutboxBuilder transactionManager(TransactionManager transactionManager) { this.transactionManager = transactionManager; return this; } /** * @param instantiator Responsible for describing a class as a name and creating instances of * that class at runtime from the name. See {@link Instantiator} for more information. * Defaults to {@link Instantiator#usingReflection()}. * @return Builder. */ public TransactionOutboxBuilder instantiator(Instantiator instantiator) { this.instantiator = instantiator; return this; } /** * @param submitter Used for scheduling background work. If no submitter is specified, {@link * TransactionOutbox} will use {@link Submitter#withDefaultExecutor()}. See {@link * Submitter#withExecutor(Executor)} for more information on designing bespoke submitters * for remoting. * @return Builder. */ public TransactionOutboxBuilder submitter(Submitter submitter) { this.submitter = submitter; return this; } /** * @param attemptFrequency How often tasks should be re-attempted. This should be balanced with * {@link #flushBatchSize} and the frequency with which {@link #flush()} is called to * achieve optimum throughput. Defaults to 2 minutes. * @return Builder. */ public TransactionOutboxBuilder attemptFrequency(Duration attemptFrequency) { this.attemptFrequency = attemptFrequency; return this; } /** * @param blacklistAfterAttempts After now many attempts a task should be blacklisted. Defaults * to 5. * @return Builder. */ public TransactionOutboxBuilder blacklistAfterAttempts(int blacklistAfterAttempts) { this.blacklistAfterAttempts = blacklistAfterAttempts; return this; } /** * @param flushBatchSize How many items should be attempted in each flush. This should be * balanced with {@link #attemptFrequency} and the frequency with which {@link #flush()} is * called to achieve optimum throughput. Defaults to 4096. * @return Builder. */ public TransactionOutboxBuilder flushBatchSize(int flushBatchSize) { this.flushBatchSize = flushBatchSize; return this; } /** * @param clockProvider The {@link Clock} source. Generally best left alone except when testing. * Defaults to the system clock. * @return Builder. */ public TransactionOutboxBuilder clockProvider(ClockProvider clockProvider) { this.clockProvider = clockProvider; return this; } /** * @param listener Event listener. Allows client code to react to tasks running, failing or * getting blacklisted. * @return Builder. */ public TransactionOutboxBuilder listener(TransactionOutboxListener listener) { this.listener = listener; return this; } /** * @param persistor The method {@link TransactionOutbox} uses to interact with the database. * This encapsulates all {@link TransactionOutbox} interaction with the database outside * transaction management (which is handled by the {@link TransactionManager}). Defaults to * a multi-platform SQL implementation that should not need to be changed in most cases. If * re-implementing this interface, read the documentation on {@link Persistor} carefully. * @return Builder. */ public TransactionOutboxBuilder persistor(Persistor persistor) { this.persistor = persistor; return this; } /** * @param logLevelTemporaryFailure The log level to use when logging temporary task failures. * Includes a full stack trace. Defaults to {@code WARN} level, but you may wish to reduce * it to a lower level if you consider warnings to be incidents. * @return Builder. */ public TransactionOutboxBuilder logLevelTemporaryFailure(Level logLevelTemporaryFailure) { this.logLevelTemporaryFailure = logLevelTemporaryFailure; return this; } /** * @param serializeMdc Determines whether to include any Slf4j {@link MDC} (Mapped Diagnostic * Context) in serialized invocations and recreate the state in submitted tasks. Defaults to * true. * @return Builder. */ public TransactionOutboxBuilder serializeMdc(Boolean serializeMdc) { this.serializeMdc = serializeMdc; return this; } /** * @param retentionThreshold The length of time that any request with a unique client id will be * remembered, such that if the same request is repeated within the threshold period, {@link * AlreadyScheduledException} will be thrown. * @return Builder. */ public TransactionOutboxBuilder retentionThreshold(Duration retentionThreshold) { this.retentionThreshold = retentionThreshold; return this; } /** * Creates and initialises the {@link TransactionOutbox}. * * @return The outbox implementation. */ public TransactionOutbox build() { return new TransactionOutbox( transactionManager, instantiator, submitter, attemptFrequency, blacklistAfterAttempts, flushBatchSize, clockProvider, listener, persistor, logLevelTemporaryFailure, serializeMdc, retentionThreshold); } } public interface ParameterizedScheduleBuilder { /** * Specifies a unique id for the request. This defaults to {@code null}, but if non-null, will * cause the request to be retained in the database after completion for the specified {@link * TransactionOutboxBuilder#retentionThreshold(Duration)}, during which time any duplicate * requests to schedule the same request id will throw {@link AlreadyScheduledException}. This * allows tasks to be scheduled idempotently even if the request itself is not idempotent (e.g. * from a message queue listener, which can usually only work reliably on an "at least once" * basis). * * @param uniqueRequestId The unique request id. May be {@code null}, but if non-null may be a * maximum of 100 characters in length. It is advised that if these ids are client-supplied, * they be prepended with some sort of context identifier to ensure global uniqueness. * @return Builder. */ ParameterizedScheduleBuilder uniqueRequestId(String uniqueRequestId); /** * Equivalent to {@link TransactionOutbox#schedule(Class)}, but applying additional parameters * to the request as configured using {@link TransactionOutbox#with()}. * *

Usage example: * *

transactionOutbox.with()
     * .uniqueRequestId("my-request")
     * .schedule(MyService.class)
     * .runMyMethod("with", "some", "arguments");
* * @param clazz The class to proxy. * @param The type to proxy. * @return The proxy of {@code T}. */ T schedule(Class clazz); } private class ParameterizedScheduleBuilderImpl implements ParameterizedScheduleBuilder { @Length(max = 100) private String uniqueRequestId; @Override public ParameterizedScheduleBuilder uniqueRequestId(String uniqueRequestId) { this.uniqueRequestId = uniqueRequestId; return this; } @Override public T schedule(Class clazz) { validator.validate(this); return TransactionOutbox.this.schedule(clazz, uniqueRequestId); } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy