Spring Data JPA, Hibernate Envers, and Stripe Integration: How @Retryable Re-executes After @Transactional Rolls Back the _AUD Entry Generating UUID_B While ch_A Stays Committed on Stripe, AuditReader.forRevisionsOfEntity() Returns Empty Inside READ COMMITTED Before Concurrent Commits Allowing Both Requests to Double-Charge, and getCurrentRevision() Allocates a New PostgreSQL Sequence Value After Rollback Causing a New Idempotency Key on Retry That Creates ch_B Alongside ch_A

The prior Spring Data JPA post in this series covered three failure modes in the basic @Transactional + Spring Retry layer: @Retryable wrapping @Transactional vs. the reverse ordering, CrudRepository.save() triggering an implicit transaction that commits before the outer @Retryable boundary sees the result, and optimistic lock ObjectOptimisticLockingFailureException retries that regenerate the Stripe idempotency key. This post moves to the Hibernate Envers audit layer and examines three failure modes that arise when developers use the Envers revision infrastructure — AuditReader, _AUD tables, and the Envers revision sequence — as a proxy for detecting prior Stripe charges. Each mode produces the same observable: Stripe records two Charge or PaymentIntent objects for the same logical billing event, each carrying a distinct Idempotency-Key header value, and the customer’s payment method is debited multiple times.

The common thread across all three modes is a conflation of the database transaction boundary with the Stripe API commitment boundary. Hibernate Envers records when a JPA entity changes within a committed database transaction. A @Transactional rollback reverses the Envers _AUD table INSERT as thoroughly as it reverses the base table INSERT. This is correct and expected. The mistake is treating the absence of an Envers revision as proof that no Stripe charge was committed. Stripe commits charges independently of any JPA transaction. A rollback on the application side has zero effect on a charge that Stripe’s API has already returned HTTP 200 for.

The three failure modes in this post are distinguishable by mechanism: the first arises from @Retryable re-executing the method body after seeing a clean Envers slate; the second arises from AuditReader queries being subject to the same READ COMMITTED isolation rules as any other query, meaning concurrent requests can both see an empty result before either commits; and the third arises from a subtle invariant about PostgreSQL sequences that is non-obvious even to experienced database engineers — namely that sequence values consumed within a rolled-back transaction are permanently consumed and never reallocated.

Background: Hibernate Envers audit table mechanics and the revision commit boundary

Hibernate Envers is an auditing module for Hibernate ORM that automatically records entity changes into shadow _AUD tables. When you annotate a JPA entity class with @Audited, Envers registers a set of Hibernate event listeners: PostInsertEventListener, PostUpdateEventListener, PostDeleteEventListener, and PostCollectionChangeEventListener. These listeners fire when Hibernate flushes the persistence context to the database. Each flush event that touches an audited entity triggers Envers to: (1) call nextval on the REVINFO sequence to allocate a revision number; (2) INSERT a row into the REVINFO table (rev, revtstmp); and (3) INSERT a row into each affected _AUD table (rev, revtype, and all audited entity columns at their current values).

The critical architectural fact: both the REVINFO INSERT and the _AUD table INSERTs happen within the same JDBC connection and the same JTA transaction as the base entity write. This means a @Transactional rollback reverts all three: the base table INSERT, the REVINFO INSERT, and the _AUD INSERT. From the database’s perspective, the entity change never happened. From Envers’s perspective, the revision never happened. An AuditReader query will see no evidence of the entity’s prior state because there is no committed revision to query.

This behavior is exactly correct for audit-trail purposes. The problem is not a bug in Envers. The problem is when this behavior is used to reason about whether a Stripe charge was committed. Stripe charges are committed by Stripe’s API server, not by the local JTA transaction manager. There is no two-phase commit protocol between Spring’s PlatformTransactionManager and Stripe’s API. When Stripe returns HTTP 200 with a charge object, that charge is committed on Stripe’s side permanently. No subsequent @Transactional rollback on the application side can reverse it. The absence of an Envers revision for a PaymentRecord entity proves the database write was rolled back. It says nothing about what Stripe did or did not commit.

For the tests in this post, the stack is: Spring Boot 3.x, spring-data-jpa, hibernate-envers, PostgreSQL 15 via Testcontainers, Spring Retry (spring-boot-starter-aop + spring-retry), WireMock for Stripe API stubs, and JUnit 5.

Mode 1: @Transactional rolls back the Envers _AUD entry alongside the base table row — @Retryable sees a clean audit trail and re-executes — UUID.randomUUID() at method entry generates UUID_B — ch_B committed alongside committed ch_A

The developer writes a billing service that charges a customer on Stripe and records the result in a @Audited JPA entity. The service is annotated with Spring Retry’s @Retryable for transient database errors. The Stripe idempotency key is generated at the top of the method with UUID.randomUUID(). The developer knows that @Transactional will roll back the database write on failure, and knows that Envers will also roll back the _AUD entry as part of the same transaction. The developer concludes: “if the transaction rolls back, there is zero evidence in the database of any prior attempt — it is as if the attempt never happened — it is therefore safe to retry.”

// PaymentRecord.java — Envers-audited entity
@Entity
@Table(name = "payment_records",
    uniqueConstraints = @UniqueConstraint(columnNames = {"subscription_id", "billing_period"}))
@Audited
public class PaymentRecord {
    @Id
    private String idempotencyKey;          // UUID_A on attempt 1, UUID_B on retry
    private String customerId;
    private String subscriptionId;
    private String billingPeriod;
    private int amountCents;
    private String stripeChargeId;
    private String status;
    // ... getters/setters
}

// BillingService.java — unsafe: UUID at method entry + @Retryable wrapping @Transactional
@Service
public class BillingService {

    @Autowired
    private PaymentRecordRepository paymentRecordRepository;

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        value = DataAccessException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    @Transactional
    public ChargeResult chargeSubscription(
            String customerId, String subscriptionId,
            String billingPeriod, int amountCents) {

        // UUID generated at method entry — new value on EVERY @Retryable attempt
        String idempotencyKey = UUID.randomUUID().toString();

        // Step 1: Stripe charge — commits ch_A on attempt 1 with key_A
        Charge charge = stripeClient.createCharge(
            customerId, amountCents, idempotencyKey);

        // Step 2: Save audited entity — triggers Envers _AUD INSERT at flush time
        PaymentRecord record = new PaymentRecord();
        record.setIdempotencyKey(idempotencyKey);       // key_A
        record.setCustomerId(customerId);
        record.setSubscriptionId(subscriptionId);
        record.setBillingPeriod(billingPeriod);
        record.setAmountCents(amountCents);
        record.setStripeChargeId(charge.getId());        // ch_A
        record.setStatus("COMPLETED");
        paymentRecordRepository.save(record);
        // DataAccessException thrown here (e.g. UNIQUE violation on subscription_id + billing_period
        // from a concurrent write, or a stale connection to a failover replica)
        // @Transactional rolls back:
        //   - payment_records INSERT (base table) — REVERTED
        //   - REVINFO INSERT (rev=1000)           — REVERTED
        //   - payment_records_aud INSERT (rev=1000) — REVERTED
        // Stripe ch_A: NOT reverted — Stripe committed it when it returned HTTP 200

        return new ChargeResult(charge.getId());
    }
}

The developer’s reasoning: “The DataAccessException caused @Transactional to roll back. Both the payment_records row and the Envers payment_records_aud revision row were rolled back. If I query AuditReader.forRevisionsOfEntity(PaymentRecord.class, key_A), I get an empty list. There is no database record of this attempt. The system is in exactly the state it would be in if this method had never been called. Therefore it is safe for @Retryable to try again.”

This reasoning is correct for every resource that is enrolled in the JTA transaction. The JPA EntityManager, the Envers event listeners, and the Envers AuditReader all operate within the JDBC connection managed by Spring’s transaction manager. A rollback reverts all writes made through that JDBC connection. The developer’s observation that the Envers audit trail shows nothing is accurate.

What the developer missed: Stripe is not enrolled in the JTA transaction. Stripe is an HTTP service reachable over a TCP connection that is wholly independent of the JDBC connection. The Stripe Java client sends an HTTP POST to https://api.stripe.com/v1/charges and blocks until it receives a response. When Stripe’s server receives the request, validates the card, and responds with HTTP 200, that charge is committed on Stripe’s side. This happens inside the application’s @Transactional block, before the paymentRecordRepository.save() call that eventually throws. The @Transactional rollback that follows reverses the JDBC writes. It has no channel, no protocol, and no mechanism to reach back into Stripe’s API and reverse a charge that Stripe has already committed.

The sequence of events for @Retryable attempt 2 is:

  1. Attempt 2 begins. @Retryable re-invokes the method. @Transactional opens a new JDBC connection and begins a new JTA transaction.
  2. Method body executes from line 1. UUID.randomUUID() generates key_B (statistically impossible to match key_A).
  3. stripeClient.createCharge(customerId, amountCents, key_B) is called. Stripe checks its idempotency table: key_B is not present (key_A is, with ch_A, but key_B is a distinct string). Stripe treats this as a new charge request and creates ch_B. The customer’s payment method is debited a second time.
  4. If paymentRecordRepository.save() succeeds on attempt 2, the database records key_B and ch_B. The customer has been charged twice: ch_A (no DB record) and ch_B (DB record).
  5. If paymentRecordRepository.save() fails again, the database records nothing. The customer has been charged twice with no application-side record of either charge.

Why the Envers audit rollback makes the misconception especially compelling

In a non-Envers scenario, the developer might at least see a blank payment_records table and reason “wait, my UNIQUE constraint on subscription_id + billing_period would have triggered if this combination was already charged — so the first attempt clearly didn’t commit to the database.” The Envers audit adds a second apparently clean signal: not only is the base table clean, but the entire audit history is clean. There are zero revisions in payment_records_aud for this entity ID. This double confirmation makes the false conclusion feel bulletproof. Both signals are accurate descriptions of the database state. Neither says anything about Stripe.

Fix: content-hash idempotency key derived from stable inputs before method entry

The fix is to derive the Stripe idempotency key from the stable inputs of the billing operation rather than from UUID.randomUUID() at method entry. A content-hash key computed from (customerId, subscriptionId, billingPeriod) is deterministic: every call with the same logical billing request produces the same key. When @Retryable re-executes the method, the content-hash produces the same key_A that was used in attempt 1. Stripe looks up key_A in its idempotency table, finds ch_A, and returns ch_A without creating a new charge. No double charge occurs.

// BillingService.java — safe: content-hash key + PENDING row before Stripe call

@Service
public class BillingService {

    @Autowired
    private PaymentRecordRepository paymentRecordRepository;

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        value = DataIntegrityViolationException.class,   // only retry on PENDING row conflict
        maxAttempts = 3,
        backoff = @Backoff(delay = 500)
    )
    @Transactional
    public ChargeResult chargeSubscription(
            String customerId, String subscriptionId,
            String billingPeriod, int amountCents) {

        // Deterministic key — same inputs always produce the same key
        String idempotencyKey = buildContentHash(customerId, subscriptionId, billingPeriod);

        // Check for prior attempt (handles: method re-called after a successful charge)
        Optional<PaymentRecord> existing =
            paymentRecordRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return new ChargeResult(existing.get().getStripeChargeId());
        }

        // Insert PENDING row BEFORE calling Stripe
        // DataIntegrityViolationException here means a concurrent request beat us —
        // @Retryable on DataIntegrityViolationException re-runs, hits existing.isPresent() above
        PaymentRecord record = new PaymentRecord();
        record.setIdempotencyKey(idempotencyKey);
        record.setCustomerId(customerId);
        record.setSubscriptionId(subscriptionId);
        record.setBillingPeriod(billingPeriod);
        record.setAmountCents(amountCents);
        record.setStatus("PENDING");
        paymentRecordRepository.saveAndFlush(record); // flush immediately to surface UNIQUE violation

        // Stripe charge — content-hash key ensures Stripe deduplicates across retries
        Charge charge = stripeClient.createCharge(customerId, amountCents, idempotencyKey);

        // Update to COMPLETED
        record.setStripeChargeId(charge.getId());
        record.setStatus("COMPLETED");
        // Envers will record TWO revisions for this entity: ADD (PENDING) and MOD (COMPLETED)

        return new ChargeResult(charge.getId());
    }

    private String buildContentHash(String customerId, String subscriptionId, String billingPeriod) {
        String raw = customerId + "|" + subscriptionId + "|" + billingPeriod;
        try {
            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            byte[] hash = sha256.digest(raw.getBytes(StandardCharsets.UTF_8));
            return "kb_" + HexFormat.of().formatHex(hash).substring(0, 40);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 not available", e);
        }
    }
}

In this design, the @Retryable annotation retries only on DataIntegrityViolationException thrown by the saveAndFlush() of the PENDING row — which means a concurrent request already inserted the PENDING row. On retry, existing.isPresent() returns the PENDING or COMPLETED record, and the method returns without calling Stripe again. The Stripe charge creation is not in the retryOn list; if Stripe throws, the exception propagates past @Retryable to the caller.

Mode 2: AuditReader.forRevisionsOfEntity() returns empty inside READ COMMITTED before a concurrent request’s transaction commits — both requests charge Stripe — ch_A and ch_B both committed

The developer uses Hibernate Envers’s AuditReader API as a pre-charge guard. The idea: before calling Stripe, query the Envers revision history for a PaymentRecord with this (customerId, contentHash) combination. If a prior revision exists, the charge was already made — return the cached result. If no revision exists, proceed with the charge. The developer reasons that READ COMMITTED isolation guarantees visibility of all committed rows, and that a committed Envers revision is therefore an authoritative signal that a prior charge happened.

// PaymentService.java — unsafe: AuditReader guard under READ COMMITTED

@Service
public class PaymentService {

    @Autowired
    private AuditReader auditReader;

    @Autowired
    private PaymentRecordRepository paymentRecordRepository;

    @Autowired
    private StripeClient stripeClient;

    @Transactional
    public ChargeResult processPayment(String customerId, String contentHash, int amountCents) {

        // Guard: query Envers for a prior committed revision with this contentHash
        @SuppressWarnings("unchecked")
        List<PaymentRecord> priorRevisions = (List<PaymentRecord>)
            auditReader.createQuery()
                .forRevisionsOfEntity(PaymentRecord.class, true, false)
                .add(AuditEntity.property("contentHash").eq(contentHash))
                .add(AuditEntity.property("customerId").eq(customerId))
                .add(AuditEntity.revisionType().eq(RevisionType.ADD))
                .getResultList();

        if (!priorRevisions.isEmpty()) {
            // Revision found — return without charging Stripe again
            return new ChargeResult(priorRevisions.get(0).getStripeChargeId());
        }

        // No revision found — proceed with charge
        Charge charge = stripeClient.createCharge(customerId, amountCents, contentHash);

        PaymentRecord record = new PaymentRecord();
        record.setContentHash(contentHash);             // used as idempotency key
        record.setCustomerId(customerId);
        record.setAmountCents(amountCents);
        record.setStripeChargeId(charge.getId());
        record.setStatus("COMPLETED");
        paymentRecordRepository.save(record);
        // Envers: payment_records_aud INSERT scheduled for flush

        return new ChargeResult(charge.getId());
    }
}

The developer’s reasoning: “forRevisionsOfEntity() queries the payment_records_aud table, which contains rows only for committed transactions. READ COMMITTED isolation means my query sees all rows committed before my statement ran. If this query returns empty, no prior transaction has committed a revision for this (customerId, contentHash) pair. The guard is correct.”

This reasoning has a fatal gap: it confuses “committed before my statement ran” with “committed before my transaction commits.” Under READ COMMITTED isolation, a query sees the committed state of the database at the moment the query’s execution begins — not at the moment the surrounding transaction began, and not at the moment the surrounding transaction ends.

The two-concurrent-request failure sequence:

  1. Request A opens a transaction. Request B opens a transaction (milliseconds later, before A commits).
  2. Request A calls forRevisionsOfEntity() with contentHash=H1. At this moment, no prior transaction has committed a revision for H1. Query returns empty. Request A proceeds past the guard.
  3. Request B calls forRevisionsOfEntity() with contentHash=H1. At this moment, Request A has not yet committed. The Envers revision from Request A (if any) is not visible. Query returns empty. Request B proceeds past the guard.
  4. Request A calls stripeClient.createCharge(customerId, amountCents, H1). Stripe processes the charge. ch_A committed.
  5. Request B calls stripeClient.createCharge(customerId, amountCents, H1). Stripe checks its idempotency table: H1 is already there from Request A’s call. Stripe returns ch_A to Request B (no second charge). Request B’s paymentRecordRepository.save() proceeds.

Wait — the Stripe idempotency key is the same (contentHash=H1) for both requests. Stripe will deduplicate at the API level: Request B gets ch_A back, not ch_B. So in this scenario, Stripe actually saves you. The double-charge doesn’t occur at the Stripe layer. It occurs at the database layer: both requests try to call paymentRecordRepository.save() with the same contentHash, and the second one hits a UNIQUE constraint violation — which is a DataIntegrityViolationException.

But consider a more dangerous variant: the developer uses UUID.randomUUID() as the Stripe key (not contentHash) and uses contentHash only for the Envers deduplication guard:

// More dangerous variant: UUID as Stripe key + AuditReader as sole guard

@Transactional
public ChargeResult processPayment(String customerId, String contentHash, int amountCents) {

    // Guard: AuditReader check (same as before)
    List<?> priorRevisions = auditReader.createQuery()
        .forRevisionsOfEntity(PaymentRecord.class, true, false)
        .add(AuditEntity.property("contentHash").eq(contentHash))
        .getResultList();

    if (!priorRevisions.isEmpty()) {
        return new ChargeResult(priorRevisions.get(0).getStripeChargeId());
    }

    // UUID as Stripe key — NOT the content hash
    String stripeKey = UUID.randomUUID().toString();   // BAD: distinct per concurrent request
    Charge charge = stripeClient.createCharge(customerId, amountCents, stripeKey);

    PaymentRecord record = new PaymentRecord(contentHash, customerId, charge.getId());
    paymentRecordRepository.save(record);

    return new ChargeResult(charge.getId());
}

Now the concurrent sequence produces a genuine double-charge:

  1. Request A: forRevisionsOfEntity(H1) → empty. Proceeds. stripeKey = UUID_A. Stripe creates ch_A.
  2. Request B: forRevisionsOfEntity(H1) → empty (A hasn’t committed yet). Proceeds. stripeKey = UUID_B. Stripe creates ch_B. (Stripe sees UUID_A and UUID_B as completely distinct keys — no deduplication.)
  3. Request A commits: payment_records INSERT (contentHash=H1) + Envers payment_records_aud INSERT.
  4. Request B’s paymentRecordRepository.save() hits UNIQUE constraint on contentHash → DataIntegrityViolationException → @Transactional rolls back. But ch_B was already committed on Stripe.

The customer now has two Stripe charges: ch_A (recorded in the database) and ch_B (no database record after Request B’s rollback). The Envers audit for Request B shows nothing — the payment_records_aud INSERT was rolled back along with the base table INSERT. From the database’s perspective, Request B never happened. From Stripe’s perspective, ch_B is committed and will show on the customer’s statement.

Why READ COMMITTED does not retroactively update completed reads

READ COMMITTED provides a guarantee about what a statement at the moment of its execution can see. It does not provide any guarantee about what will be visible to subsequent statements in the same transaction, or about whether writes committed after your statement ran will be retroactively visible to your transaction. Once Request B’s forRevisionsOfEntity() query completes, the result is fixed. It reflects the committed state of payment_records_aud at the instant that query executed. If Request A commits its revision one millisecond later, Request B’s already-completed query is not updated. Request B already has its “empty” result and has already moved past the guard.

This is the fundamental limitation of using a read as a concurrency guard. Reads are snapshots. Writes are events. The gap between the snapshot and the subsequent action is the window for a race.

Fix: write a PENDING row before calling Stripe; use the UNIQUE constraint as the concurrent guard

The correct concurrent guard is a write, not a read. INSERT the PENDING row into payment_records before calling Stripe. The UNIQUE constraint on contentHash makes the INSERT serializable across concurrent requests: exactly one request will succeed in inserting the PENDING row. Any other concurrent request that tries to insert the same contentHash will receive a DataIntegrityViolationException immediately, before reaching the Stripe call. This transforms the concurrency hazard from a silent double-charge into a loud constraint violation that is caught and handled before any money moves.

// PaymentService.java — safe: PENDING row as concurrent guard

@Transactional
public ChargeResult processPayment(String customerId, String contentHash, int amountCents) {

    // Check for prior completed or in-progress attempt
    Optional<PaymentRecord> existing =
        paymentRecordRepository.findByContentHash(contentHash);
    if (existing.isPresent()) {
        PaymentRecord rec = existing.get();
        if ("COMPLETED".equals(rec.getStatus())) {
            return new ChargeResult(rec.getStripeChargeId());
        }
        // PENDING: concurrent request is in-flight — let the caller retry later
        throw new PaymentInProgressException("Payment already in progress: " + contentHash);
    }

    // INSERT PENDING row — UNIQUE constraint is the concurrent guard
    // DataIntegrityViolationException here = concurrent request beat us to the INSERT
    PaymentRecord record = new PaymentRecord(contentHash, customerId, amountCents, "PENDING");
    paymentRecordRepository.saveAndFlush(record); // immediate flush to surface constraint violation

    // Stripe — contentHash as key ensures Stripe deduplicates if called more than once
    Charge charge = stripeClient.createCharge(customerId, amountCents, contentHash);

    record.setStripeChargeId(charge.getId());
    record.setStatus("COMPLETED");
    // Envers records: ADD (PENDING) + MOD (COMPLETED) — two revisions per completed payment

    return new ChargeResult(charge.getId());
}

The AuditReader is still useful here — for querying billing history, for compliance reporting, for building an audit dashboard. Its role is changed from a concurrency guard (which it cannot reliably be) to a historical record (which it excels at).

Mode 3: AuditReader.getCurrentRevision(true) forces a REVINFO sequence allocation that is permanently consumed on @Transactional rollback — retry allocates a new sequence value — Stripe idempotency key changes from rev-1000 to rev-1001 — ch_B created alongside committed ch_A

This failure mode is specific to a pattern where the developer uses the Envers revision number as the Stripe idempotency key. The developer’s reasoning: “The Envers revision is a unique, transaction-scoped identifier. One @Transactional execution produces exactly one revision. If I use the revision ID as the Stripe key, and the transaction is retried, the retry will get the same revision and therefore send the same key to Stripe, which will return the already-created charge without creating a new one.”

This reasoning has a subtle but fatal flaw rooted in how PostgreSQL implements sequences. Sequences in PostgreSQL are not transactional. A call to nextval() on a sequence increments the sequence counter and returns the new value immediately. If the calling transaction subsequently rolls back, the sequence counter does not go back. The sequence value is consumed permanently. This is intentional: making sequences transactional would require locking the sequence for the duration of every transaction that uses it, which would serialize all concurrent writes to the same table and defeat the purpose of sequences for high-concurrency inserts.

Hibernate Envers allocates revision numbers by calling nextval on the REVINFO sequence. When AuditReader.getCurrentRevision(MyRevisionEntity.class, true) is called with persist=true, Envers immediately inserts a row into the REVINFO table within the current transaction, consuming a sequence value. If the transaction rolls back, the REVINFO INSERT is rolled back — but the sequence counter has already advanced. The next call to getCurrentRevision(true) in a new transaction will get the next sequence value, not the rolled-back one.

// BillingService.java — unsafe: Envers revision ID as Stripe idempotency key

@Service
public class BillingService {

    @Autowired
    private AuditReader auditReader;

    @Autowired
    private PaymentRecordRepository paymentRecordRepository;

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        value = DataAccessException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    @Transactional
    public ChargeResult chargeCustomer(String customerId, int amountCents) {

        // Developer's idea: use the Envers revision ID as the Stripe idempotency key.
        // "The revision is transaction-scoped and unique — if the transaction is retried,
        // a new revision is created and therefore a new key. But wait, I need the SAME
        // key on retry to let Stripe deduplicate. So I'll fetch the existing revision if
        // this is a retry by checking the REVINFO table..." — but the rollback means there
        // IS no prior REVINFO row. Each @Retryable attempt gets a brand-new revision ID.
        //
        // Here the developer believes getCurrentRevision() returns the SAME ID on retry
        // because they think @Transactional rollback also rolled back the sequence increment.
        MyRevisionEntity revision = auditReader.getCurrentRevision(MyRevisionEntity.class, true);
        // persist=true: immediately INSERT INTO revinfo (rev, revtstmp) VALUES (1000, ...)
        // This INSERT is inside the current JTA transaction.

        String idempotencyKey = "rev-" + revision.getId();   // "rev-1000" on attempt 1

        // Stripe charge with "rev-1000" — ch_A committed
        Charge charge = stripeClient.createCharge(customerId, amountCents, idempotencyKey);

        // JPA save — @Audited entity, Envers queues _AUD INSERT
        PaymentRecord record = new PaymentRecord(customerId, amountCents, charge.getId());
        paymentRecordRepository.save(record);
        // DataAccessException thrown here
        // @Transactional rollback:
        //   - payment_records INSERT               — REVERTED
        //   - REVINFO INSERT (rev=1000)             — REVERTED
        //   - payment_records_aud INSERT (rev=1000) — REVERTED
        //   - PostgreSQL SEQUENCE revinfo_seq       — NOT reverted: still at 1000
        //     (sequence counters are non-transactional in PostgreSQL)

        return new ChargeResult(charge.getId());
    }
}

// @Retryable attempt 2:
//   @Transactional opens new transaction
//   getCurrentRevision(true) calls nextval(revinfo_seq) → 1001 (not 1000 — 1000 is gone)
//   INSERT INTO revinfo (rev=1001)
//   idempotencyKey = "rev-1001"
//   stripeClient.createCharge(customerId, amountCents, "rev-1001")
//   Stripe idempotency table: "rev-1000" → ch_A (from attempt 1), "rev-1001" → not found
//   Stripe creates ch_B with key "rev-1001"
//   Customer double-charged: ch_A (no DB record) + ch_B (DB record if save() succeeds)

The PostgreSQL sequence non-transactional invariant in detail

The PostgreSQL documentation states explicitly: “To avoid blocking concurrent transactions that obtain numbers from the same sequence, a nextval operation is never rolled back; that is, once a value has been fetched it is considered used and will not be returned again.” This is not a quirk or an implementation detail — it is a specification guarantee. It applies to all sequence operations in PostgreSQL, including those called indirectly by Hibernate Envers via its revision sequence.

The implication for the developer’s mental model: there is no scenario in which two @Transactional executions of the same service method will produce the same Envers revision number, even if the first execution is rolled back. The first execution consumes revision 1000. The rollback reverts the REVINFO row. But the sequence counter is at 1000. The second execution gets 1001. A third execution (if @Retryable allows three attempts) gets 1002. Each attempt silently uses a new Stripe idempotency key and silently creates a new charge.

The developer can verify this behavior directly:

-- After a rolled-back transaction that called getCurrentRevision(true):
SELECT last_value FROM revinfo_seq;
-- Returns: 1000 (or wherever the sequence is after the rollback)

SELECT * FROM revinfo WHERE rev = 1000;
-- Returns: 0 rows (the INSERT was rolled back)

-- Gap: rev=1000 will NEVER appear in revinfo.
-- The next INSERT into revinfo (from any session) will use rev=1001.

This gap in the REVINFO sequence is a diagnostic signal. If an operational team ever queries the REVINFO table for gaps between consecutive rev values, those gaps correspond directly to failed (rolled-back) transactions that touched Envers-audited entities. Each gap is a transaction that called nextval but was rolled back before committing the REVINFO row. In the double-charge scenario, each gap also corresponds to a Stripe charge that was committed but has no database record.

Why the developer’s mental model breaks down at the sequence layer

The developer’s intuition that “@Transactional rollback undoes everything getCurrentRevision(true) did” is approximately correct for the REVINFO row and the _AUD rows. Both are standard JDBC writes within the JTA transaction. Both are rolled back. The intuition breaks down at the sequence level because sequences in PostgreSQL are stored in a separate, non-MVCC data structure. The sequence counter is updated with a lightweight lock, not a full MVCC write. Rolling back the calling transaction does not roll back the sequence counter update.

Developers who have experience with Oracle sequences have the same mental model, because Oracle sequences also do not roll back on transaction abort. Developers who have experience with MySQL’s AUTO_INCREMENT may have a weaker version of this intuition, since MySQL’s AUTO_INCREMENT also does not roll back gaps. The pattern — “gaps in an auto-increment sequence indicate rolled-back transactions” — is well-known and discussed in every major SQL tutorial. What is less well-known is that this gap-creation behavior means the sequence value is consumed from the caller’s perspective before the transaction commits, and therefore cannot be used as a stable, repeatable identifier across retries of the same transaction.

Fix: derive the Stripe idempotency key from stable business inputs, never from database-generated sequence values

The correct idempotency key must be derivable from inputs that are stable across all retry attempts of the same logical operation. Database-generated values (sequences, AUTO_INCREMENT IDs, UUID.randomUUID(), Envers revision numbers) are all ineligible because they are guaranteed to change across retries. The only eligible inputs are the business inputs to the operation: the customer ID, the subscription ID, the billing period, the order ID, the invoice number — any combination of business-meaningful values that uniquely identifies the logical billing event and is present at the method call site, not generated inside the method body.

// BillingService.java — safe: content-hash key from business inputs

@Service
public class BillingService {

    @Autowired
    private PaymentRecordRepository paymentRecordRepository;

    @Autowired
    private StripeClient stripeClient;

    @Retryable(
        value = DataIntegrityViolationException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 500)
    )
    @Transactional
    public ChargeResult chargeCustomer(String customerId, String invoiceId, int amountCents) {

        // Stable key — same across all @Retryable attempts for the same invoice
        String idempotencyKey = buildContentHash(customerId, invoiceId);

        // Pre-charge check
        Optional<PaymentRecord> existing =
            paymentRecordRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return new ChargeResult(existing.get().getStripeChargeId());
        }

        // PENDING row — UNIQUE constraint is the concurrency guard
        PaymentRecord record = new PaymentRecord();
        record.setIdempotencyKey(idempotencyKey);
        record.setCustomerId(customerId);
        record.setInvoiceId(invoiceId);
        record.setAmountCents(amountCents);
        record.setStatus("PENDING");
        paymentRecordRepository.saveAndFlush(record);
        // Envers revision: ADD with rev=1000 (or whatever the current sequence value is)

        // Stripe — idempotencyKey is stable across retries
        Charge charge = stripeClient.createCharge(customerId, amountCents, idempotencyKey);

        // Update to COMPLETED
        record.setStripeChargeId(charge.getId());
        record.setStatus("COMPLETED");
        // Envers revision: MOD with rev=1001 (next in sequence, stable commit)

        return new ChargeResult(charge.getId());
    }

    private String buildContentHash(String customerId, String invoiceId) {
        String raw = customerId + "|" + invoiceId;
        try {
            byte[] hash = MessageDigest.getInstance("SHA-256")
                .digest(raw.getBytes(StandardCharsets.UTF_8));
            return "kb_" + HexFormat.of().formatHex(hash).substring(0, 40);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

The Envers revision numbers will still have gaps when transactions are rolled back. That is normal and expected. The difference is that the Stripe idempotency key is no longer tied to the revision number. The key is now derived entirely from business inputs that are stable across retries. Gaps in the REVINFO sequence are still useful as a diagnostic signal for failed transactions, but they no longer have any bearing on Stripe charge deduplication.

Integration tests for all three modes

All three failure modes are reproducible in automated tests using Testcontainers (PostgreSQL 15), WireMock for Stripe stubs, and Spring Boot’s @SpringBootTest. The tests verify the failure in the unsafe code and the correct behavior in the fixed code.

// BillingServiceIntegrationTest.java

@SpringBootTest
@Testcontainers
class BillingServiceIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb").withUsername("test").withPassword("test");

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort()).build();

    @Autowired BillingService billingService;
    @Autowired PaymentRecordRepository paymentRecordRepository;
    @Autowired AuditReader auditReader;
    @Autowired DataSource dataSource;

    // ---- Mode 1: @Retryable re-executes after _AUD rollback --- //

    @Test
    void mode1_retryableRegeneratesUUID_doubleChargesStripe() throws Exception {
        // Arrange: Stripe stub — first two calls succeed with different charge IDs
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("double-charge")
            .whenScenarioStateIs(STARTED)
            .willReturn(okJson("""
                {"id":"ch_A","object":"charge","status":"succeeded"}
                """))
            .willSetStateTo("second-call"));
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .inScenario("double-charge")
            .whenScenarioStateIs("second-call")
            .willReturn(okJson("""
                {"id":"ch_B","object":"charge","status":"succeeded"}
                """)));

        // First attempt: Stripe succeeds, DB save fails due to constraint violation
        // Inject a constraint violation on the first save attempt via a trigger
        simulateConstraintViolationOnFirstSave();

        // Act: call the unsafe service (UUID at method entry)
        billingService.chargeSubscriptionUnsafe("cus_1", "sub_1", "2026-10", 4999);

        // Assert: Stripe received two calls with different idempotency keys
        List<LoggedRequest> stripeRequests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(2);
        String key1 = extractIdempotencyKey(stripeRequests.get(0));
        String key2 = extractIdempotencyKey(stripeRequests.get(1));
        assertThat(key1).isNotEqualTo(key2);   // different keys = double charge

        // Envers: only the SECOND attempt's revision is committed (first was rolled back)
        List<?> revisions = auditReader.createQuery()
            .forRevisionsOfEntity(PaymentRecord.class, true, false).getResultList();
        assertThat(revisions).hasSize(1);       // only ch_B's record exists in DB
    }

    @Test
    void mode1_fixed_contentHashKey_stripeDeduplicates() throws Exception {
        // Arrange: Stripe stub — returns ch_A for the content-hash key on both calls
        String expectedKey = computeExpectedContentHash("cus_1", "sub_1", "2026-10");
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .withHeader("Idempotency-Key", equalTo(expectedKey))
            .willReturn(okJson("""
                {"id":"ch_A","object":"charge","status":"succeeded"}
                """)));

        simulateConstraintViolationOnFirstSave();

        // Act: call the safe service (content-hash key + PENDING row)
        billingService.chargeSubscriptionSafe("cus_1", "sub_1", "2026-10", 4999);

        // Assert: Stripe received only ONE call (or two with the SAME key)
        List<LoggedRequest> stripeRequests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
        assertThat(stripeRequests).allSatisfy(req ->
            assertThat(extractIdempotencyKey(req)).isEqualTo(expectedKey));
    }

    // ---- Mode 2: AuditReader forRevisionsOfEntity race under READ COMMITTED ---- //

    @Test
    void mode2_concurrentAuditReaderGuard_doubleChargesStripe() throws Exception {
        // Arrange: Stripe stubs for two distinct UUID keys
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .willReturn(okJson("{\"id\":\"ch_DYNAMIC\",\"object\":\"charge\",\"status\":\"succeeded\"}")));

        // Act: two concurrent threads both call the unsafe service
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Exception> threadBError = new AtomicReference<>();

        Thread threadA = new Thread(() -> {
            try {
                paymentServiceUnsafe.processPaymentWithAuditGuard("cus_2", "hash_H1", 2999);
            } catch (Exception e) { /* ignore — may succeed */ }
        });
        Thread threadB = new Thread(() -> {
            try {
                latch.await(100, TimeUnit.MILLISECONDS);
                paymentServiceUnsafe.processPaymentWithAuditGuard("cus_2", "hash_H1", 2999);
            } catch (DataIntegrityViolationException e) {
                // expected — but ch_B already committed on Stripe
            } catch (Exception e) {
                threadBError.set(e);
            }
        });

        threadA.start();
        latch.countDown();
        threadB.start();
        threadA.join(); threadB.join();

        // Both threads called Stripe — two distinct UUID keys
        List<LoggedRequest> stripeRequests = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
        assertThat(stripeRequests).hasSize(2);
        assertThat(extractIdempotencyKey(stripeRequests.get(0)))
            .isNotEqualTo(extractIdempotencyKey(stripeRequests.get(1)));
    }

    // ---- Mode 3: getCurrentRevision(true) sequence gap on rollback ---- //

    @Test
    void mode3_revisionIdAsStripeKey_sequenceGapCreatesNewKeyOnRetry() throws Exception {
        // Arrange: Stripe responds for two distinct "rev-NNNN" keys
        wireMock.stubFor(post(urlEqualTo("/v1/charges"))
            .withHeader("Idempotency-Key", matching("rev-\\d+"))
            .willReturn(okJson("{\"id\":\"ch_REV\",\"object\":\"charge\",\"status\":\"succeeded\"}")));

        simulateConstraintViolationOnFirstSave();

        // Act: call the unsafe service that uses Envers revision ID as Stripe key
        billingService.chargeCustomerUnsafeRevisionKey("cus_3", 1999);

        // Assert: Stripe received two calls with DIFFERENT "rev-NNNN" keys
        List<LoggedRequest> reqs = wireMock.findAll(postRequestedFor(urlEqualTo("/v1/charges")));
        assertThat(reqs).hasSizeGreaterThanOrEqualTo(2);
        String revKey1 = extractIdempotencyKey(reqs.get(0)); // "rev-1000"
        String revKey2 = extractIdempotencyKey(reqs.get(1)); // "rev-1001"
        assertThat(revKey1).isNotEqualTo(revKey2);

        // Verify: REVINFO has a gap (rev=1000 is missing, rev=1001 is present)
        Long rev1000Count = (Long) entityManager
            .createNativeQuery("SELECT COUNT(*) FROM revinfo WHERE rev = :rev")
            .setParameter("rev", extractRevNumber(revKey1))
            .getSingleResult();
        assertThat(rev1000Count).isZero();   // rolled back — gap confirmed

        Long rev1001Count = (Long) entityManager
            .createNativeQuery("SELECT COUNT(*) FROM revinfo WHERE rev = :rev")
            .setParameter("rev", extractRevNumber(revKey2))
            .getSingleResult();
        assertThat(rev1001Count).isOne();    // committed
    }
}

Comparison table: three Hibernate Envers + Stripe failure modes

Mode Root cause Trigger Developer misconception Detection signal Fix
1. @Retryable + _AUD rollback UUID.randomUUID() at method entry; @Transactional rollback removes both base row and Envers revision; @Retryable re-executes with new UUID paymentRecordRepository.save() throws DataAccessException after Stripe already committed ch_A “No Envers revision for this entity ID means no Stripe charge was committed” Stripe dashboard: two charges with distinct Idempotency-Key values for same customer in same billing period; application DB has record for ch_B only Content-hash key from stable business inputs; PENDING row INSERT before Stripe call; @Retryable retries only on DataIntegrityViolationException from PENDING row
2. AuditReader concurrent race forRevisionsOfEntity() query sees committed state at query-execution time under READ COMMITTED; concurrent request has not yet committed its Envers revision; both requests proceed to Stripe with distinct UUIDs Two concurrent requests for the same logical billing event arrive within the same commit window “READ COMMITTED means I see all committed rows; if AuditReader returns empty, no prior charge was committed” Stripe dashboard: two charges with distinct keys for same customer; second request’s DB save throws DataIntegrityViolationException; ch_B visible in Stripe but not in DB PENDING row INSERT before Stripe call; UNIQUE constraint on (customerId, contentHash) serializes concurrent requests; AuditReader used for audit queries, not concurrency control
3. getCurrentRevision(true) sequence gap PostgreSQL sequences are non-transactional; getCurrentRevision(true) consumes sequence value on rollback; retry allocates a new sequence value; Envers revision ID used as Stripe key changes between attempts paymentRecordRepository.save() throws DataAccessException after Stripe committed ch_A under key rev-1000; retry uses rev-1001 “getCurrentRevision() is transaction-scoped; rollback reverts the revision ID; retry reuses the same ID” Gaps in REVINFO sequence (rev=1000 absent, rev=1001 present); Stripe dashboard: charges under both rev-1000 and rev-1001 for same customer Content-hash key from stable business inputs; never use database-generated values (sequences, UUIDs, revision IDs) as Stripe idempotency keys across retry boundaries

What Hibernate Envers is and is not

Hibernate Envers is an audit trail mechanism. It records what changed, when it changed, and (with a custom @RevisionEntity) who changed it. The _AUD tables and the AuditReader API are designed for after-the-fact querying: “what was this entity’s state at revision 500?” or “who modified this record and when?” They are not designed for concurrency control, and they cannot be used as a reliable signal for whether an external API call (such as a Stripe charge) was committed.

The three failure modes in this post arise when Envers is asked to do more than it was designed to do: to serve as evidence that no Stripe charge was committed (Mode 1), to serve as a concurrent guard that prevents duplicate charges (Mode 2), or to provide a stable, retryable identifier for Stripe idempotency (Mode 3). In all three cases, the same pattern fixes the issue: use a content-hash key derived from stable business inputs as the Stripe idempotency key, and use a PENDING row in the base table with a UNIQUE constraint as the concurrent guard. The Envers audit trail then does what it does best — recording the full history of each payment record, including the PENDING-to-COMPLETED transition — without being asked to do anything it cannot reliably do.

Keybrake and Spring Data JPA + Hibernate Envers Stripe integrations

All three failure modes in this post share a detection problem: they are silent. Stripe returns HTTP 200 for each charge. The application does not throw an unhandled exception visible in logs (except for the DataIntegrityViolationException on the second paymentRecordRepository.save() in Mode 2, but that exception is often silently swallowed by @Retryable or a global exception handler). The Envers audit trail shows only committed data, which is the correct behavior — but the committed data corresponds to the successful attempts, not the failed ones that created Stripe charges with no database records.

The gap between Stripe’s view and the application’s view is the core of the problem. Stripe has committed ch_A, ch_B, and potentially ch_C. The application’s payment_records table and Envers payment_records_aud table show only the charges from transactions that committed. The charges from rolled-back transactions — which are committed on Stripe’s side — are invisible to the application until a manual reconciliation job queries Stripe’s API and compares it against the application’s records.

Keybrake operates as a proxy layer between your Spring application and Stripe. Every request through the proxy is logged with its idempotency key and the resulting charge ID. When a rolled-back transaction’s Stripe call produces ch_A under key_A and the subsequent retry produces ch_B under key_B, Keybrake’s audit log captures both: two charge events for the same customer within a short window, each with a distinct key, neither referencing the other. This is the earliest possible detection point for the double-charge — before any reconciliation job, before the developer looks at the Stripe dashboard, immediately after the second charge is committed. Keybrake’s per-vendor daily spend cap is a second line of defense: a stuck retry loop that produces dozens of charges before the bug is noticed will trip the cap and halt further charges, bounding the financial damage to a threshold you set before the agent runs.

Get notified when Keybrake ships

Keybrake enforces per-vendor spend caps and gives you a kill-switch for the Stripe, Twilio, and Resend keys your agents use. Early access list below.