Class InMemoryOutboxRecordRepository

java.lang.Object
com.aim2be.platform.test.InMemoryOutboxRecordRepository

public class InMemoryOutboxRecordRepository extends Object
In-memory OutboxRecordRepository that replaces the JPA-backed repository in test scope. Backed by a ConcurrentHashMap keyed on OutboxRecord.PK and a TestOutboxRecordCaptor that records every save(...) for downstream assertions.

The repository surface mirrors the production JPA repository's contract for the methods the outbox-publisher hot relay + cold poller call:

Methods inherited from JpaRepository but NOT used by the outbox library throw UnsupportedOperationException via the JDK dynamic-proxy fall-through in create(TestOutboxRecordCaptor). Downstream tests that need a specific inherited method should extend this class via its protected constructor and override the fall-through.

Thread-safety: the backing ConcurrentMap guarantees thread-safe puts and gets; status transitions (markSent, markFailureAttempt) use ConcurrentMap#computeIfPresent so the read-modify-write sequence on a single PK is atomic with respect to the map's presence check + apply (no torn updates between concurrent computeIfPresent callers for the same key). This is NOT, however, equivalent to the JPA path's row-level SELECT ... FOR UPDATE: the stored OutboxRecord is a mutable reference shared with any prior findById caller, so a concurrent reader can observe a row mid-transition (e.g. status=SENT but sentAt=null) — production JPA row-level locking would block the reader until the writer's transaction commits. Test code MUST treat any OutboxRecord returned by findById or the captor's snapshots as a moment-in-time view, not a frozen snapshot (R1 finding — previous Javadoc overstated the row-locking equivalence).

  • Constructor Details

    • InMemoryOutboxRecordRepository

      protected InMemoryOutboxRecordRepository(TestOutboxRecordCaptor captor)
      Creates an in-memory repository wiring captures to captor.
      Parameters:
      captor - the test captor; non-null
  • Method Details

    • create

      public static OutboxRecordRepository create(TestOutboxRecordCaptor captor)
      Factory: returns a dynamic proxy that exposes the in-memory repository as an OutboxRecordRepository. The proxy intercepts every interface method, dispatches the supported methods to this class, and throws UnsupportedOperationException for the rest.
      Parameters:
      captor - the test captor; non-null
      Returns:
      a proxy implementing OutboxRecordRepository
    • save

      public OutboxRecord save(OutboxRecord record)
      Persists a row and captures it for assertions. Mirrors CrudRepository.save(Object).
      Parameters:
      record - the row to persist; non-null with non-null compound PK
      Returns:
      the persisted row (same instance)
    • saveAndFlush

      public OutboxRecord saveAndFlush(OutboxRecord record)
      Variant of save(OutboxRecord) that mirrors the JPA saveAndFlush semantics (in-memory has no buffer, so flush is a no-op).
      Parameters:
      record - the row to persist; non-null
      Returns:
      the persisted row (same instance)
    • findById

      public Optional<OutboxRecord> findById(OutboxRecord.PK pk)
      Returns the row for the compound PK, or empty when absent.
      Parameters:
      pk - the compound primary key; non-null
      Returns:
      the row, or empty
    • findByStatusOrderByCreatedAtAsc

      public org.springframework.data.domain.Page<OutboxRecord> findByStatusOrderByCreatedAtAsc(OutboxRecord.Status status, org.springframework.data.domain.Pageable pageable)
      Returns rows in status, sorted by createdAt ascending, paginated per pageable.
      Parameters:
      status - target status; non-null
      pageable - bounded page; non-null
      Returns:
      matching rows in FIFO creation order
    • countByStatus

      public long countByStatus(OutboxRecord.Status status)
      Returns the total row count for a given status.
      Parameters:
      status - target status; non-null
      Returns:
      total matching rows
    • findOldestCreatedAtByStatus

      public Optional<Instant> findOldestCreatedAtByStatus(OutboxRecord.Status status)
      Returns the minimum createdAt for rows in status, or empty when no rows match.
      Parameters:
      status - target status; non-null
      Returns:
      oldest createdAt or empty
    • markSent

      public int markSent(UUID aggregateId, UUID eventId, Instant sentAt)
      Atomic status-guarded PENDING → SENT transition mirroring the production JPQL UPDATE. The ConcurrentMap#computeIfPresent call serialises concurrent transitions on the same row, so a late second-writer that races with another success path observes the row already in OutboxRecord.Status.SENT and returns rowcount 0.
      Parameters:
      aggregateId - compound-PK component; non-null
      eventId - compound-PK component; non-null
      sentAt - wall-clock instant; non-null
      Returns:
      1 on applied transition; 0 on no-op (row absent or already terminal)
    • markFailureAttempt

      public int markFailureAttempt(UUID aggregateId, UUID eventId, String lastError, int maxRetries)
      Atomic status-guarded retry-bump on publish failure. Mirrors the production JPQL UPDATE's CASE WHEN budget-exhaustion semantics: when the post-increment retries count reaches maxRetries, the row transitions to OutboxRecord.Status.FAILED in the same operation.
      Parameters:
      aggregateId - compound-PK component; non-null
      eventId - compound-PK component; non-null
      lastError - error message; truncated by OutboxRecord.setLastError(String) when over OutboxRecord.LAST_ERROR_MAX_LENGTH chars
      maxRetries - retry budget; row transitions to FAILED when retries + 1 >= maxRetries
      Returns:
      1 on applied increment; 0 on no-op (row absent or already terminal)
    • deleteAll

      public void deleteAll()
      Clears the in-memory backing store. Used between tests that share a Spring context.

      Does NOT touch the TestOutboxRecordCaptor. The captor is a separate test-observation surface — tests that want to reset captures across a shared context must call TestOutboxRecordCaptor.clear() explicitly (R1 finding — previous Javadoc claimed "every captured row + the backing store" but the implementation only clears rows).

    • count

      public long count()
      Returns the total number of stored rows (any status).
      Returns:
      row count
    • flush

      public void flush()
      JPA flush no-op for in-memory backing. Present so the proxy can route JpaRepository#flush() without throwing.