Skip to content

Add regression for executor transaction isolation#1913

Open
graemerocher wants to merge 1 commit into7.0.xfrom
fix-issue-1508-executor-transaction-isolation
Open

Add regression for executor transaction isolation#1913
graemerocher wants to merge 1 commit into7.0.xfrom
fix-issue-1508-executor-transaction-isolation

Conversation

@graemerocher
Copy link
Copy Markdown
Contributor

Summary

Adds JDBI transaction coverage for executor-submitted work started from afterCommit, asserting the follow-up write runs in an independent transaction.

Verification

  • ./gradlew :micronaut-jdbi:txTest --console=plain

Resolves #1508

@graemerocher graemerocher added the type: bug Something isn't working label Apr 15, 2026
@graemerocher graemerocher self-assigned this Apr 15, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a regression test to validate that work submitted to an executor from a afterCommit callback can start and complete in its own transaction (JDBI + Micronaut transactions), addressing #1508.

Changes:

  • Added ExecutorTransactionIsolationService to execute an outer write transaction and then schedule an inner write after commit.
  • Added a Spock spec asserting the async follow-up work completes successfully and persists data.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
jdbi/src/txTest/groovy/io/micronaut/configuration/jdbi/example/jdbitransaction/ExecutorTransactionIsolationService.java Implements the after-commit executor submission + nested write to reproduce/guard the isolation behavior.
jdbi/src/txTest/groovy/io/micronaut/configuration/jdbi/example/ApplicationSpec.groovy Adds a regression test that drives the service and validates data persistence.

Comment on lines +135 to +138
def count = service.executeAsyncTransactionAfterCommit()

then:
count == 2
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the test asserts the follow-up write runs in an independent transaction, but count == 2 only proves both inserts eventually happened. To actually assert transaction isolation/independence, consider having ExecutorTransactionIsolationService capture and expose evidence that the inner callback ran in a distinct transaction (e.g., compare outer vs inner connection identity from the transaction status, or assert via transaction status flags / a unique transaction marker) and assert that in this spec.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +53
executorService.submit(() -> {
try {
transactionOperations.executeWrite(inner -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(11, 'inner')"));
return null;
});
} catch (Throwable e) {
failure.set(e);
} finally {
completed.countDown();
}
});
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executorService.submit(...) can throw (e.g., RejectedExecutionException if the executor is shut down). If that happens, the latch is never counted down and the method will always time out, masking the real failure. Wrap the submit call itself in a try/catch that sets failure and counts down completed when submission fails.

Suggested change
executorService.submit(() -> {
try {
transactionOperations.executeWrite(inner -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(11, 'inner')"));
return null;
});
} catch (Throwable e) {
failure.set(e);
} finally {
completed.countDown();
}
});
try {
executorService.submit(() -> {
try {
transactionOperations.executeWrite(inner -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(11, 'inner')"));
return null;
});
} catch (Throwable e) {
failure.set(e);
} finally {
completed.countDown();
}
});
} catch (Throwable e) {
failure.set(e);
completed.countDown();
}

Copilot uses AI. Check for mistakes.
AtomicReference<Throwable> failure = new AtomicReference<>();

transactionOperations.executeWrite(status -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(10, 'outer')"));
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using hard-coded primary keys (10/11) makes this test more fragile if the table isn’t reliably recreated/emptied (e.g., prior failures, parallel execution, or reuse across specs). Consider generating IDs dynamically (or letting the DB auto-generate) and/or ensuring initialize() truncates/recreates the table so the test is robust.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +45
public int executeAsyncTransactionAfterCommit() throws InterruptedException {
CountDownLatch completed = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();

transactionOperations.executeWrite(status -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(10, 'outer')"));
status.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executorService.submit(() -> {
try {
transactionOperations.executeWrite(inner -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(11, 'inner')"));
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using hard-coded primary keys (10/11) makes this test more fragile if the table isn’t reliably recreated/emptied (e.g., prior failures, parallel execution, or reuse across specs). Consider generating IDs dynamically (or letting the DB auto-generate) and/or ensuring initialize() truncates/recreates the table so the test is robust.

Suggested change
public int executeAsyncTransactionAfterCommit() throws InterruptedException {
CountDownLatch completed = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
transactionOperations.executeWrite(status -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(10, 'outer')"));
status.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executorService.submit(() -> {
try {
transactionOperations.executeWrite(inner -> {
jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(11, 'inner')"));
private int nextBookId() {
return jdbi.withHandle(handle ->
handle.createQuery("SELECT COALESCE(MAX(id), 0) + 1 FROM books")
.mapTo(Integer.class)
.one()
);
}
public int executeAsyncTransactionAfterCommit() throws InterruptedException {
CountDownLatch completed = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
transactionOperations.executeWrite(status -> {
int outerBookId = nextBookId();
jdbi.useHandle(handle -> handle.createUpdate("INSERT INTO books(id, name) VALUES(:id, :name)")
.bind("id", outerBookId)
.bind("name", "outer")
.execute());
status.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executorService.submit(() -> {
try {
transactionOperations.executeWrite(inner -> {
int innerBookId = nextBookId();
jdbi.useHandle(handle -> handle.createUpdate("INSERT INTO books(id, name) VALUES(:id, :name)")
.bind("id", innerBookId)
.bind("name", "inner")
.execute());

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +135
when:
def dbSetup = applicationContext.getBean(DatabaseSetup)
def service = applicationContext.getBean(ExecutorTransactionIsolationService)
dbSetup.initialize()
def count = service.executeAsyncTransactionAfterCommit()
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup: runs even if the when: block fails partway through. If an exception occurs before dbSetup is initialized successfully, dbSetup.drop() can throw a NullPointerException that obscures the original failure. Consider guarding the cleanup (if (dbSetup != null)) or using a pattern that ensures drop() is only called when initialization completed.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +142
cleanup:
dbSetup.drop()
applicationContext.close()
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup: runs even if the when: block fails partway through. If an exception occurs before dbSetup is initialized successfully, dbSetup.drop() can throw a NullPointerException that obscures the original failure. Consider guarding the cleanup (if (dbSetup != null)) or using a pattern that ensures drop() is only called when initialization completed.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transaction can't spin off additional, unrelated transactional work via ExecutorService

2 participants