Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.micronaut.configuration.jdbi.example

import io.micronaut.configuration.jdbi.example.jdbitransaction.ExecutorTransactionIsolationService
import io.micronaut.configuration.jdbi.example.jdbitransaction.ConcurrentTransactionsBug
import io.micronaut.context.ApplicationContext
import io.micronaut.context.DefaultApplicationContext
Expand Down Expand Up @@ -117,4 +118,27 @@ class ApplicationSpec extends Specification {
dbSetup.drop()
applicationContext.close()
}

def "test executor work after commit uses a separate transaction"() {
given:
ApplicationContext applicationContext = new DefaultApplicationContext("test")
applicationContext.environment.addPropertySource(MapPropertySource.of(
'test',
['datasources.default': [:]]
))
applicationContext.start()

when:
def dbSetup = applicationContext.getBean(DatabaseSetup)
def service = applicationContext.getBean(ExecutorTransactionIsolationService)
dbSetup.initialize()
def count = service.executeAsyncTransactionAfterCommit()
Comment on lines +131 to +135
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.

then:
count == 2
Comment on lines +135 to +138
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.

cleanup:
dbSetup.drop()
applicationContext.close()
Comment on lines +140 to +142
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.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.micronaut.configuration.jdbi.example.jdbitransaction;

import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.transaction.TransactionOperations;
import io.micronaut.transaction.support.TransactionSynchronization;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import org.jdbi.v3.core.Jdbi;

import java.sql.Connection;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

@Singleton
public class ExecutorTransactionIsolationService {

private final Jdbi jdbi;
private final TransactionOperations<Connection> transactionOperations;
private final ExecutorService executorService;

public ExecutorTransactionIsolationService(
Jdbi jdbi,
@Named("default") TransactionOperations<Connection> transactionOperations,
@Named(TaskExecutors.SCHEDULED) ExecutorService executorService
) {
this.jdbi = jdbi;
this.transactionOperations = transactionOperations;
this.executorService = executorService;
}

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')"));
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.
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')"));
Comment on lines +33 to +45
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.
return null;
});
} catch (Throwable e) {
failure.set(e);
} finally {
completed.countDown();
}
});
Comment on lines +42 to +53
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.
}
});
return null;
});

if (!completed.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("Timed out waiting for asynchronous transactional work");
}
Throwable throwable = failure.get();
if (throwable instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (throwable != null) {
throw new RuntimeException(throwable);
}
return transactionOperations.executeRead(status ->
jdbi.withHandle(handle -> handle.createQuery("SELECT COUNT(*) FROM books").mapTo(Integer.class).one())
);
}
}
Loading