Add regression for executor transaction isolation#1913
Add regression for executor transaction isolation#1913graemerocher wants to merge 1 commit into7.0.xfrom
Conversation
Co-Authored-By: Codex with GPT-5 <[email protected]>
There was a problem hiding this comment.
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
ExecutorTransactionIsolationServiceto 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. |
| def count = service.executeAsyncTransactionAfterCommit() | ||
|
|
||
| then: | ||
| count == 2 |
There was a problem hiding this comment.
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.
| 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(); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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.
| 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(); | |
| } |
| AtomicReference<Throwable> failure = new AtomicReference<>(); | ||
|
|
||
| transactionOperations.executeWrite(status -> { | ||
| jdbi.useHandle(handle -> handle.execute("INSERT INTO books(id, name) VALUES(10, 'outer')")); |
There was a problem hiding this comment.
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.
| 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')")); |
There was a problem hiding this comment.
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.
| 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()); |
| when: | ||
| def dbSetup = applicationContext.getBean(DatabaseSetup) | ||
| def service = applicationContext.getBean(ExecutorTransactionIsolationService) | ||
| dbSetup.initialize() | ||
| def count = service.executeAsyncTransactionAfterCommit() |
There was a problem hiding this comment.
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.
| cleanup: | ||
| dbSetup.drop() | ||
| applicationContext.close() |
There was a problem hiding this comment.
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.
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=plainResolves #1508