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

com.gruelbox.transactionoutbox.testing.AbstractAcceptanceTest Maven / Gradle / Ivy

There is a newer version: 6.0.553
Show newest version
package com.gruelbox.transactionoutbox.testing;

import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.junit.jupiter.api.Assertions.*;

import com.gruelbox.transactionoutbox.*;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.IntStream;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Slf4j
public abstract class AbstractAcceptanceTest extends BaseTest {

  private static final Logger LOGGER = LoggerFactory.getLogger(AbstractAcceptanceTest.class);

  private ExecutorService unreliablePool;
  private ExecutorService singleThreadPool;

  private static final Random random = new Random();

  @BeforeEach
  void beforeEachBase() {
    unreliablePool =
        new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(16));
    singleThreadPool = Executors.newSingleThreadExecutor();
  }

  @AfterEach
  void afterEachBase() throws InterruptedException {
    unreliablePool.shutdown();
    singleThreadPool.shutdown();
    assertTrue(unreliablePool.awaitTermination(30, SECONDS));
    assertTrue(singleThreadPool.awaitTermination(30, SECONDS));
  }

  @Test
  final void sequencing() throws Exception {
    int countPerTopic = 20;
    int topicCount = 5;

    AtomicInteger insertIndex = new AtomicInteger();
    CountDownLatch latch = new CountDownLatch(countPerTopic * topicCount);
    ThreadLocalContextTransactionManager transactionManager =
        (ThreadLocalContextTransactionManager) txManager();

    transactionManager.inTransaction(
        tx -> {
          //noinspection resource
          try (var stmt = tx.connection().createStatement()) {
            stmt.execute("DROP TABLE TEST_TABLE");
          } catch (SQLException e) {
            // ignore
          }
        });

    transactionManager.inTransaction(
        tx -> {
          //noinspection resource
          try (var stmt = tx.connection().createStatement()) {
            stmt.execute(createTestTable());
          } catch (SQLException e) {
            throw new RuntimeException(e);
          }
        });

    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .submitter(Submitter.withExecutor(unreliablePool))
            .attemptFrequency(Duration.ofMillis(500))
            .instantiator(
                new RandomFailingInstantiator(
                    (foo, bar) -> {
                      transactionManager.requireTransaction(
                          tx -> {
                            //noinspection resource
                            try (var stmt =
                                tx.connection()
                                    .prepareStatement(
                                        "INSERT INTO TEST_TABLE (topic, ix, foo) VALUES(?, ?, ?)")) {
                              stmt.setString(1, bar);
                              stmt.setInt(2, insertIndex.incrementAndGet());
                              stmt.setInt(3, foo);
                              stmt.executeUpdate();
                            } catch (SQLException e) {
                              throw new RuntimeException(e);
                            }
                          });
                    }))
            .persistor(persistor())
            .listener(new LatchListener(latch))
            .initializeImmediately(false)
            .flushBatchSize(4)
            .build();

    outbox.initialize();
    clearOutbox();

    withRunningFlusher(
        outbox,
        () -> {
          transactionManager.inTransaction(
              () -> {
                for (int i = 1; i <= countPerTopic; i++) {
                  for (int j = 1; j <= topicCount; j++) {
                    outbox
                        .with()
                        .ordered("topic" + j)
                        .schedule(InterfaceProcessor.class)
                        .process(i, "topic" + j);
                  }
                }
              });
          assertTrue(latch.await(30, SECONDS));
        });

    var output = new HashMap>();
    transactionManager.inTransaction(
        tx -> {
          //noinspection resource
          try (var stmt = tx.connection().createStatement();
              var rs = stmt.executeQuery("SELECT topic, foo FROM TEST_TABLE ORDER BY ix")) {
            while (rs.next()) {
              ArrayList values =
                  output.computeIfAbsent(rs.getString(1), k -> new ArrayList<>());
              values.add(rs.getInt(2));
            }
          } catch (SQLException e) {
            throw new RuntimeException(e);
          }
        });

    var indexes = IntStream.range(1, countPerTopic + 1).boxed().collect(toList());
    var expected =
        IntStream.range(1, topicCount + 1)
            .mapToObj(i -> "topic" + i)
            .collect(toMap(it -> it, it -> indexes));
    assertEquals(expected, output);
  }

  /**
   * Uses a simple direct transaction manager and connection manager and attempts to fire an
   * interface using a custom instantiator.
   */
  @Test
  final void simpleConnectionProviderCustomInstantiatorInterfaceClass()
      throws InterruptedException {

    TransactionManager transactionManager = txManager();

    CountDownLatch latch = new CountDownLatch(1);
    CountDownLatch chainedLatch = new CountDownLatch(1);
    AtomicBoolean gotScheduled = new AtomicBoolean();
    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .instantiator(
                Instantiator.using(
                    clazz ->
                        (InterfaceProcessor)
                            (foo, bar) -> LOGGER.info("Processing ({}, {})", foo, bar)))
            .submitter(Submitter.withExecutor(unreliablePool))
            .listener(
                new LatchListener(latch)
                    .andThen(
                        new TransactionOutboxListener() {

                          @Override
                          public void scheduled(TransactionOutboxEntry entry) {
                            log.info("Got scheduled event");
                            gotScheduled.set(true);
                          }

                          @Override
                          public void success(TransactionOutboxEntry entry) {
                            chainedLatch.countDown();
                          }
                        }))
            .persistor(persistor())
            .initializeImmediately(false)
            .build();

    outbox.initialize();
    clearOutbox();

    transactionManager.inTransaction(
        () -> {
          outbox.schedule(InterfaceProcessor.class).process(3, "Whee");
          try {
            // Should not be fired until after commit
            assertFalse(latch.await(2, SECONDS));
          } catch (InterruptedException e) {
            fail("Interrupted");
          }
        });

    // Should be fired after commit
    assertTrue(chainedLatch.await(2, SECONDS));
    assertTrue(latch.await(1, SECONDS));
    assertTrue(gotScheduled.get());
  }

  @Test
  final void noAutomaticInitialization() {

    TransactionManager transactionManager = txManager();
    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .instantiator(
                Instantiator.using(
                    clazz ->
                        (InterfaceProcessor)
                            (foo, bar) -> LOGGER.info("Processing ({}, {})", foo, bar)))
            .submitter(Submitter.withDefaultExecutor())
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .initializeImmediately(false)
            .build();

    Persistor.forDialect(connectionDetails().dialect()).migrate(txManager());
    clearOutbox();

    Assertions.assertThrows(
        IllegalStateException.class,
        () ->
            transactionManager.inTransaction(
                () -> outbox.schedule(InterfaceProcessor.class).process(3, "Whee")));
  }

  @Test
  void duplicateRequests() {

    TransactionManager transactionManager = txManager();

    List ids = new ArrayList<>();
    AtomicReference clockProvider = new AtomicReference<>(Clock.systemDefaultZone());

    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .listener(
                new TransactionOutboxListener() {
                  @Override
                  public void success(TransactionOutboxEntry entry) {
                    ids.add((String) entry.getInvocation().getArgs()[0]);
                  }
                })
            .submitter(Submitter.withExecutor(Runnable::run))
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .retentionThreshold(Duration.ofDays(2))
            .clockProvider(clockProvider::get)
            .build();

    clearOutbox();

    // Schedule some work
    transactionManager.inTransaction(
        () ->
            outbox
                .with()
                .uniqueRequestId("context-clientkey1")
                .schedule(ClassProcessor.class)
                .process("1"));

    // Make sure we can schedule more work with a different client key
    transactionManager.inTransaction(
        () ->
            outbox
                .with()
                .uniqueRequestId("context-clientkey2")
                .schedule(ClassProcessor.class)
                .process("2"));

    // Make sure we can't repeat the same work
    transactionManager.inTransaction(
        () ->
            Assertions.assertThrows(
                AlreadyScheduledException.class,
                () ->
                    outbox
                        .with()
                        .uniqueRequestId("context-clientkey1")
                        .schedule(ClassProcessor.class)
                        .process("3")));

    // Run the clock forward to just under the retention threshold
    clockProvider.set(
        Clock.fixed(
            clockProvider.get().instant().plus(Duration.ofDays(2)).minusSeconds(60),
            clockProvider.get().getZone()));
    outbox.flush();

    // Make sure we can schedule more work with a different client key
    transactionManager.inTransaction(
        () ->
            outbox
                .with()
                .uniqueRequestId("context-clientkey4")
                .schedule(ClassProcessor.class)
                .process("4"));

    // Make sure we still can't repeat the same work
    transactionManager.inTransaction(
        () ->
            Assertions.assertThrows(
                AlreadyScheduledException.class,
                () ->
                    outbox
                        .with()
                        .uniqueRequestId("context-clientkey1")
                        .schedule(ClassProcessor.class)
                        .process("5")));

    // Run the clock over the threshold
    clockProvider.set(
        Clock.fixed(clockProvider.get().instant().plusSeconds(120), clockProvider.get().getZone()));
    outbox.flush();

    // We should now be able to add the work
    transactionManager.inTransaction(
        () ->
            outbox
                .with()
                .uniqueRequestId("context-clientkey1")
                .schedule(ClassProcessor.class)
                .process("6"));

    assertThat(ids, containsInAnyOrder("1", "2", "4", "6"));
  }

  /**
   * Uses a simple data source transaction manager and attempts to fire a concrete class via
   * reflection.
   */
  @Test
  final void dataSourceConnectionProviderReflectionInstantiatorConcreteClass()
      throws InterruptedException {
    try (HikariDataSource ds = dataSource) {

      CountDownLatch latch = new CountDownLatch(1);

      TransactionManager transactionManager = TransactionManager.fromDataSource(ds);
      TransactionOutbox outbox =
          TransactionOutbox.builder()
              .transactionManager(transactionManager)
              .persistor(Persistor.forDialect(connectionDetails().dialect()))
              .listener(new LatchListener(latch))
              .build();

      clearOutbox();
      ClassProcessor.PROCESSED.clear();
      String myId = UUID.randomUUID().toString();

      transactionManager.inTransaction(() -> outbox.schedule(ClassProcessor.class).process(myId));

      assertTrue(latch.await(2, SECONDS));
      assertEquals(List.of(myId), ClassProcessor.PROCESSED);
    }
  }

  /**
   * Implements a custom transaction manager. Any required changes to this test are a sign that we
   * need to bump the major revision.
   */
  @Test
  final void customTransactionManager()
      throws ClassNotFoundException, SQLException, InterruptedException {

    Class.forName(connectionDetails().driverClassName());
    try (Connection connection =
        DriverManager.getConnection(
            connectionDetails().url(),
            connectionDetails().user(),
            connectionDetails().password())) {

      connection.setAutoCommit(false);
      connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

      ArrayList postCommitHooks = new ArrayList<>();
      ArrayList preparedStatements = new ArrayList<>();
      CountDownLatch latch = new CountDownLatch(1);

      Transaction transaction =
          new Transaction() {
            @Override
            public Connection connection() {
              return connection;
            }

            @Override
            @SneakyThrows
            public PreparedStatement prepareBatchStatement(String sql) {
              var stmt = connection.prepareStatement(sql);
              preparedStatements.add(stmt);
              return stmt;
            }

            @Override
            public void addPostCommitHook(Runnable runnable) {
              postCommitHooks.add(runnable);
            }
          };

      TransactionManager transactionManager =
          new ThreadLocalContextTransactionManager() {
            @Override
            public  T inTransactionReturnsThrows(
                ThrowingTransactionalSupplier work) throws E {
              return work.doWork(transaction);
            }

            @Override
            public  T requireTransactionReturns(
                ThrowingTransactionalSupplier work) throws E, NoTransactionActiveException {
              return work.doWork(transaction);
            }
          };

      TransactionOutbox outbox =
          TransactionOutbox.builder()
              .transactionManager(transactionManager)
              .listener(new LatchListener(latch))
              .persistor(Persistor.forDialect(connectionDetails().dialect()))
              .build();

      clearOutbox();
      ClassProcessor.PROCESSED.clear();
      String myId = UUID.randomUUID().toString();

      try {
        outbox.schedule(ClassProcessor.class).process(myId);
        preparedStatements.forEach(
            it -> {
              try {
                it.executeBatch();
                it.close();
              } catch (SQLException e) {
                throw new RuntimeException(e);
              }
            });
        connection.commit();
      } catch (Exception e) {
        connection.rollback();
        throw e;
      }
      postCommitHooks.forEach(Runnable::run);

      assertTrue(latch.await(2, SECONDS));
      assertEquals(List.of(myId), ClassProcessor.PROCESSED);
    }
  }

  /**
   * Runs a piece of work which will fail several times before working successfully. Ensures that
   * the work runs eventually.
   */
  @Test
  final void retryBehaviour() throws Exception {
    TransactionManager transactionManager = txManager();
    CountDownLatch latch = new CountDownLatch(1);
    AtomicInteger attempts = new AtomicInteger();
    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .instantiator(new FailingInstantiator(attempts))
            .submitter(Submitter.withExecutor(singleThreadPool))
            .attemptFrequency(Duration.ofMillis(500))
            .listener(new LatchListener(latch))
            .build();

    clearOutbox();

    withRunningFlusher(
        outbox,
        () -> {
          transactionManager.inTransaction(
              () -> outbox.schedule(InterfaceProcessor.class).process(3, "Whee"));
          assertTrue(latch.await(15, SECONDS));
        },
        singleThreadPool);
  }

  @Test
  final void onSchedulingFailure_BubbleExceptionsUp() throws Exception {
    Assumptions.assumeTrue(
        Dialect.MY_SQL_8.equals(connectionDetails().dialect())
            || Dialect.MY_SQL_5.equals(connectionDetails().dialect()));

    TransactionManager transactionManager = txManager();
    CountDownLatch latch = new CountDownLatch(1);
    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .instantiator(
                Instantiator.using(
                    clazz ->
                        (InterfaceProcessor)
                            (foo, bar) ->
                                LOGGER.info(
                                    "Entered the method to process successfully. Processing ({}, {})",
                                    foo,
                                    bar)))
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .submitter(Submitter.withExecutor(unreliablePool))
            .attemptFrequency(Duration.ofMillis(500))
            .listener(new LatchListener(latch))
            .build();

    clearOutbox();

    withRunningFlusher(
        outbox,
        () ->
            assertThrows(
                Exception.class,
                () ->
                    transactionManager.inTransaction(
                        () ->
                            outbox
                                .with()
                                .uniqueRequestId("some_unique_id")
                                .schedule(InterfaceProcessor.class)
                                .process(1, "This invocation is too long".repeat(650000)))));
  }

  @Test
  final void lastAttemptTime_updatesEveryTime() throws Exception {
    TransactionManager transactionManager = txManager();
    CountDownLatch successLatch = new CountDownLatch(1);
    CountDownLatch blockLatch = new CountDownLatch(1);
    AtomicInteger attempts = new AtomicInteger();
    var orderedEntryListener = new OrderedEntryListener(successLatch, blockLatch);
    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .instantiator(new FailingInstantiator(attempts))
            .submitter(Submitter.withExecutor(singleThreadPool))
            .attemptFrequency(Duration.ofMillis(500))
            .listener(orderedEntryListener)
            .blockAfterAttempts(2)
            .build();

    clearOutbox();

    withRunningFlusher(
        outbox,
        () -> {
          transactionManager.inTransaction(
              () -> outbox.schedule(InterfaceProcessor.class).process(3, "Whee"));
          assertTrue(blockLatch.await(20, SECONDS), "Entry was not blocked");
          assertTrue(
              (Boolean)
                  transactionManager.inTransactionReturns(
                      tx -> outbox.unblock(orderedEntryListener.getBlocked().getId())));
          assertTrue(successLatch.await(20, SECONDS), "Timeout waiting for success");
          var events = orderedEntryListener.getEvents();
          log.info("The entry life cycle is: {}", events);

          // then we are only dealing in terms of a single outbox entry.
          assertEquals(1, events.stream().map(TransactionOutboxEntry::getId).distinct().count());
          // the first, scheduled entry has no lastAttemptTime set
          assertNull(events.get(0).getLastAttemptTime());
          // all subsequent entries (2 x failures (second of which 'blocks'), 1x success updates
          // against db) have a distinct lastAttemptTime set on them.
          assertEquals(
              3,
              events.stream()
                  .skip(1)
                  .map(TransactionOutboxEntry::getLastAttemptTime)
                  .distinct()
                  .count());
        },
        singleThreadPool);
  }

  /**
   * Runs a piece of work which will fail enough times to enter a blocked state but will then pass
   * when re-tried after it is unblocked.
   */
  @Test
  final void blockAndThenUnblockForRetry() throws Exception {
    TransactionManager transactionManager = txManager();
    CountDownLatch successLatch = new CountDownLatch(1);
    CountDownLatch blockLatch = new CountDownLatch(1);
    LatchListener latchListener = new LatchListener(successLatch, blockLatch);
    AtomicInteger attempts = new AtomicInteger();
    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .instantiator(new FailingInstantiator(attempts))
            .submitter(Submitter.withExecutor(singleThreadPool))
            .attemptFrequency(Duration.ofMillis(500))
            .listener(latchListener)
            .blockAfterAttempts(2)
            .build();

    clearOutbox();

    withRunningFlusher(
        outbox,
        () -> {
          transactionManager.inTransaction(
              () -> outbox.schedule(InterfaceProcessor.class).process(3, "Whee"));
          assertTrue(blockLatch.await(5, SECONDS));
          assertTrue(
              (Boolean)
                  transactionManager.inTransactionReturns(
                      tx -> outbox.unblock(latchListener.getBlocked().getId())));
          assertTrue(successLatch.await(5, SECONDS));
        },
        singleThreadPool);
  }

  /** Hammers high-volume, frequently failing tasks to ensure that they all get run. */
  @Test
  final void highVolumeUnreliable() throws Exception {
    int count = 10;

    TransactionManager transactionManager = txManager();
    CountDownLatch latch = new CountDownLatch(count * 10);
    ConcurrentHashMap results = new ConcurrentHashMap<>();
    ConcurrentHashMap duplicates = new ConcurrentHashMap<>();

    TransactionOutbox outbox =
        TransactionOutbox.builder()
            .transactionManager(transactionManager)
            .persistor(Persistor.forDialect(connectionDetails().dialect()))
            .instantiator(new RandomFailingInstantiator())
            .submitter(Submitter.withExecutor(unreliablePool))
            .attemptFrequency(Duration.ofMillis(500))
            .flushBatchSize(1000)
            .listener(
                new TransactionOutboxListener() {
                  @Override
                  public void success(TransactionOutboxEntry entry) {
                    Integer i = (Integer) entry.getInvocation().getArgs()[0];
                    if (results.putIfAbsent(i, i) != null) {
                      duplicates.put(i, i);
                    }
                    latch.countDown();
                  }
                })
            .build();

    withRunningFlusher(
        outbox,
        () -> {
          IntStream.range(0, count)
              .parallel()
              .forEach(
                  i ->
                      transactionManager.inTransaction(
                          () -> {
                            for (int j = 0; j < 10; j++) {
                              outbox.schedule(InterfaceProcessor.class).process(i * 10 + j, "Whee");
                            }
                          }));
          assertTrue(latch.await(30, SECONDS), "Latch not opened in time");
        });

    assertThat(
        "Should never get duplicates running to full completion", duplicates.keySet(), empty());
    assertThat(
        "Only got: " + results.keySet(),
        results.keySet(),
        containsInAnyOrder(IntStream.range(0, count * 10).boxed().toArray()));
  }

  protected String createTestTable() {
    return "CREATE TABLE TEST_TABLE (topic VARCHAR(50), ix INTEGER, foo INTEGER, PRIMARY KEY (topic, ix))";
  }

  private static class FailingInstantiator implements Instantiator {

    private final AtomicInteger attempts;

    FailingInstantiator(AtomicInteger attempts) {
      this.attempts = attempts;
    }

    @Override
    public String getName(Class clazz) {
      return "BEEF";
    }

    @Override
    public Object getInstance(String name) {
      if (!"BEEF".equals(name)) {
        throw new UnsupportedOperationException();
      }
      return (InterfaceProcessor)
          (foo, bar) -> {
            LOGGER.info("Processing ({}, {})", foo, bar);
            if (attempts.incrementAndGet() < 3) {
              throw new RuntimeException("Temporary failure");
            }
            LOGGER.info("Processed ({}, {})", foo, bar);
          };
    }
  }

  private static class RandomFailingInstantiator implements Instantiator {

    private final InterfaceProcessor interfaceProcessor;

    RandomFailingInstantiator() {
      this.interfaceProcessor = (foo, bar) -> {};
    }

    RandomFailingInstantiator(InterfaceProcessor interfaceProcessor) {
      this.interfaceProcessor = interfaceProcessor;
    }

    @Override
    public String getName(Class clazz) {
      return clazz.getName();
    }

    @Override
    public Object getInstance(String name) {
      if (InterfaceProcessor.class.getName().equals(name)) {
        return (InterfaceProcessor)
            (foo, bar) -> {
              if (random.nextInt(10) == 5) {
                throw new RuntimeException("Temporary failure of InterfaceProcessor");
              }
              interfaceProcessor.process(foo, bar);
            };
      } else {
        throw new UnsupportedOperationException();
      }
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy