diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java index 255e5bde7d9..cbb5faafc66 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java @@ -24,6 +24,7 @@ import com.codename1.backend.DataSource; import com.codename1.backend.Database; +import com.codename1.backend.Transactions; import com.codename1.backend.sql.Dialect; import java.io.IOException; import java.util.List; @@ -65,6 +66,22 @@ public Object run(Database connection) throws Exception { // end::backend-database-transaction[] } + public static void registerSynchronization() { +// tag::backend-transaction-synchronization[] +Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { + // Validate application state before the ORM flush and database commit. + } + public void afterCommit() { + // Invalidate a cache or schedule an event only after a successful commit. + } + public void afterCompletion(Transactions.Outcome outcome) { + // Dispose resources on COMMITTED, ROLLED_BACK, or UNKNOWN. + } +}); +// end::backend-transaction-synchronization[] + } + public static String schema(DataSource db) { // tag::backend-database-dialect[] Dialect dialect = db.dialect(); diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index 5d056ac7097..bde5b5deee7 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -546,6 +546,100 @@ reported with `TransactionException.TimedOut`. It doesn't interrupt a statement that's running, so it bounds how long a transaction can take to commit rather than how long any one query may run. +==== Transaction isolation + +`@Transactional(isolation = Isolation.SERIALIZABLE)` selects isolation for the +physical transaction. Import `com.codename1.backend.annotations.Isolation`. +`DEFAULT` preserves the database's configured default. An explicit isolation +request in a joined `REQUIRED`, `SUPPORTS`, `MANDATORY`, or `NESTED` call must match +the outer transaction's explicit level. Joining a `DEFAULT` transaction with an +explicit level fails, even if that database happens to use the requested default. +Choose the level on the outer method, or use `REQUIRES_NEW` for an independent one. +Isolation on a scope that runs without a transaction is rejected. + +[cols="1,3",options="header"] +|=== +| Engine | Explicit levels +| SQLite | `SERIALIZABLE`. A connection configured with `PRAGMA read_uncommitted` +is rejected for that request. Other explicit levels are unsupported. +| PostgreSQL | `READ_COMMITTED`, `REPEATABLE_READ`, `SERIALIZABLE`. +`READ_UNCOMMITTED` is rejected instead of mapping it to `READ_COMMITTED`. +| MySQL/MariaDB | `READ_UNCOMMITTED`, `READ_COMMITTED`, `REPEATABLE_READ`, `SERIALIZABLE`. +Use transactional tables such as InnoDB. +|=== + +Support is checked when the lazy transaction first borrows a connection, before +application SQL executes. A failure to acquire a connection or start a transaction +marks the scope rollback-only. +PostgreSQL uses transaction-local `BEGIN` characteristics; MySQL uses a +next-transaction setting. Neither changes the session default. SQLite's supported +level requires no setting change. Commit and rollback therefore leave pooled +connections with their original defaults; connections with uncertain state are +discarded. This also applies to read-only transactions. + +These choices follow the engines' transaction controls: +https://www.postgresql.org/docs/current/sql-set-transaction.html[PostgreSQL], +https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html[MySQL], and +https://www.sqlite.org/isolation.html[SQLite]. + +For programmatic work, `Transactions.execute(Propagation.REQUIRED, false, -1, +Isolation.SERIALIZABLE, work)` provides the same thread-bound scope. Its +`Transactions.Work.run()` body returns a value and may throw an exception; every +body exception or `Error` rolls back. `Transactions.execute(work)` uses `REQUIRED` +and default isolation. `DataSource.inTransaction(Isolation, Work)` also creates or +joins this scope, and pool operations within its body join it. + +For a connection-local transaction, `Database.beginTransaction(boolean, Isolation)` +and `Database.transaction(Isolation, Work)` select isolation without creating a +thread-bound scope. The existing `DataSource.inTransaction(Work)` retains its +connection-local behavior when called outside a declarative transaction. Use +`Transactions.execute` or the isolation overload when callbacks/resources are needed. + +==== Completion hooks and transaction resources + +Register a `Transactions.Synchronization` within a thread-bound transaction: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/DatabaseSnippets.java[tag=backend-transaction-synchronization,indent=0] +---- + +Hooks run synchronously in registration order within each phase. `beforeCommit` +runs before the session flush; it may use the transaction's database connection. +It receives the physical transaction's read-only flag, including for read-only +transactions. Read-only scopes still run all phases. Registration closes when +completion starts, so a callback can't register further hooks on that scope. +A before-commit exception or `Error` aborts the remaining before hooks and rolls +back. Rollback-only and timeout checks run again after before hooks. + +`afterCommit` runs only after the database acknowledges commit. A failure in one +hook doesn't stop later after-commit hooks or completion hooks; the caller gets +`TransactionException` reporting that the transaction committed but its callback +failed. Don't retry the whole transaction based on that exception alone. +`afterCompletion` runs once per registration at the end of each physical scope, +including one that never needed a connection. Its failures are logged, and later +cleanup still runs. Exception-driven rollback preserves the body's original +exception. A commit failure or failed rollback reports `UNKNOWN` because database +durability can't be established; no after-commit hooks run in that case. + +The session closes and the connection returns to the pool before after-commit +and completion hooks. During those hooks `Transactions.isActive()` is false; +database work must use `REQUIRES_NEW` or an explicitly suspended scope. + +`Transactions.bindResource(key, value)` binds a non-null value under a non-null +key. Duplicate bindings fail. `getResource(key)` returns the binding or null; +`unbindResource(key)` removes it. Bindings remain available to all completion +hooks and are cleared afterward, even if a hook fails. Values aren't implicitly +closed: use a completion hook for disposal. + +`REQUIRED` participants share the physical scope's hooks and bindings. +`REQUIRES_NEW` suspends both and restores them when its independent scope ends, +even after failure. `NESTED` shares the physical bindings: savepoint rollback does +not undo arbitrary Java objects. Hooks registered inside a rolled-back nested +scope skip their before/after-commit phases, but still receive the physical +transaction's final outcome in `afterCompletion`. Successful nested hooks wait +for the outer commit; releasing a savepoint never triggers a commit hook. + ==== Savepoints `NESTED` runs a method inside the open transaction, behind a savepoint. A failure diff --git a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java index c482e1d64f3..208bee5ab19 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -23,6 +23,7 @@ package com.codename1.backend; import com.codename1.impl.backend.Scheduler; +import com.codename1.backend.annotations.Isolation; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.params.ParameterizedTest; @@ -69,6 +70,79 @@ private static void insert(DataSource pool, String v) throws IOException { pool.execute("INSERT INTO cn1_tx_probe (v) VALUES (?)", new Object[] {v}); } + @ParameterizedTest + @ValueSource(strings = {"POSTGRES", "MYSQL"}) + void explicitIsolationDoesNotLeakIntoPooledConnections(String engine) throws Exception { + DataSource pool = open(engine); + Database db = pool.borrow(); + try { + String query = "POSTGRES".equals(engine) ? "SHOW transaction_isolation" + : "SELECT @@session.transaction_isolation AS transaction_isolation"; + Object baseline; + try { + baseline = db.queryOne(query, null).get("transaction_isolation"); + } catch (IOException oldMySqlOrMariaDb) { + if (!"MYSQL".equals(engine)) { throw oldMySqlOrMariaDb; } + query = "SELECT @@session.tx_isolation AS transaction_isolation"; + baseline = db.queryOne(query, null).get("transaction_isolation"); + } + for (Isolation isolation : new Isolation[] { + Isolation.READ_COMMITTED, + Isolation.REPEATABLE_READ, + Isolation.SERIALIZABLE}) { + for (boolean rollback : new boolean[] {false, true}) { + db.beginTransaction(false, isolation); + if ("POSTGRES".equals(engine)) { + assertEquals(isolation.name().toLowerCase(java.util.Locale.ROOT).replace('_', ' '), + db.queryOne(query, null).get("transaction_isolation")); + } + db.execute("INSERT INTO cn1_tx_probe VALUES ('isolation')", null); + if (rollback) { db.rollbackTransaction(); } else { db.commitTransaction(); } + pool.release(db); + Database next = pool.borrow(); + org.junit.jupiter.api.Assertions.assertSame(db, next); + assertFalse(next.isInTransaction()); + assertEquals(baseline, next.queryOne(query, null).get("transaction_isolation")); + } + } + if ("POSTGRES".equals(engine)) { + final Database connection = db; + assertThrows(IOException.class, () -> connection.beginTransaction(false, + Isolation.READ_UNCOMMITTED)); + assertFalse(db.isInTransaction()); + } + } finally { + if (db.isInTransaction()) { db.rollbackTransaction(); } + pool.release(db); + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"POSTGRES", "MYSQL"}) + void explicitRepeatableReadKeepsSnapshotAndReadCommittedSeesNewRows(String engine) throws Exception { + DataSource pool = open(engine); + Database observer = pool.borrow(); + try { + for (Isolation isolation : new Isolation[] { + Isolation.READ_COMMITTED, + Isolation.REPEATABLE_READ}) { + observer.beginTransaction(true, isolation); + int before = ((Number) observer.queryOne("SELECT COUNT(*) AS n FROM cn1_tx_probe", null).get("n")).intValue(); + insert(pool, "other-connection"); + int after = ((Number) observer.queryOne("SELECT COUNT(*) AS n FROM cn1_tx_probe", null).get("n")).intValue(); + assertEquals(before + (isolation == Isolation.READ_COMMITTED ? 1 : 0), after); + observer.commitTransaction(); + } + } finally { + if (observer.isInTransaction()) { observer.rollbackTransaction(); } + pool.release(observer); + pool.execute("DROP TABLE IF EXISTS cn1_tx_probe", null); + pool.close(); + } + } + @ParameterizedTest @ValueSource(strings = {"POSTGRES", "MYSQL"}) void commitRollbackAndSavepoints(String engine) throws Exception { diff --git a/maven/backend/src/test/java/com/codename1/backend/TransactionSynchronizationTest.java b/maven/backend/src/test/java/com/codename1/backend/TransactionSynchronizationTest.java new file mode 100644 index 00000000000..20f1db8ad86 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/TransactionSynchronizationTest.java @@ -0,0 +1,455 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import com.codename1.backend.annotations.Isolation; +import com.codename1.backend.annotations.Propagation; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class TransactionSynchronizationTest { + @TempDir File dir; + + private DataSource pool() throws IOException { + DataSource pool = DataSource.open(new File(dir, "sync.db").getAbsolutePath(), 1, 1000, 10000); + pool.execute("CREATE TABLE IF NOT EXISTS t (v INTEGER)", null); + return pool; + } + + private Transactions.Synchronization hook(final List events, final String name) { + return new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { events.add(name + ":before:" + readOnly); } + public void afterCommit() { events.add(name + ":commit"); } + public void afterCompletion(Transactions.Outcome outcome) { events.add(name + ":" + outcome); } + }; + } + + @Test void orderedHooksAndResourcesWithoutDatabase() throws Exception { + final List events = new ArrayList(); + final Object[] cleanup = new Object[2]; + Transactions.execute(Propagation.REQUIRED, true, -1, Isolation.DEFAULT, () -> { + Transactions.bindResource("key", "value"); + assertThrows(TransactionException.IllegalState.class, () -> Transactions.bindResource("key", "other")); + Transactions.registerSynchronization(hook(events, "a")); + Transactions.registerSynchronization(hook(events, "b")); + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void afterCompletion(Transactions.Outcome outcome) { + cleanup[0] = Transactions.getResource("key"); + cleanup[1] = Transactions.unbindResource("key"); + } + }); + return null; + }); + assertArrayEquals(new Object[] {"value", "value"}, cleanup); + assertEquals(Arrays.asList("a:before:true", "b:before:true", "a:commit", "b:commit", + "a:COMMITTED", "b:COMMITTED"), events); + assertNull(Transactions.getResource("key")); + assertFalse(Transactions.isActive()); + assertThrows(TransactionException.IllegalState.class, () -> Transactions.bindResource("x", "y")); + } + + @Test void requiresNewSuspendsAndRestoresCallbacksAndResources() throws Exception { + final List events = new ArrayList(); + Transactions.execute(() -> { + Transactions.bindResource("key", "outer"); + Transactions.registerSynchronization(hook(events, "outer")); + Transactions.execute(Propagation.REQUIRES_NEW, false, -1, Isolation.SERIALIZABLE, () -> { + assertNull(Transactions.getResource("key")); + Transactions.bindResource("key", "inner"); + Transactions.registerSynchronization(hook(events, "inner")); + return null; + }); + assertEquals("outer", Transactions.getResource("key")); + Transactions.setRollbackOnly(); + return null; + }); + assertEquals(Arrays.asList("inner:before:false", "inner:commit", "inner:COMMITTED", "outer:ROLLED_BACK"), events); + assertNull(Transactions.getResource("key")); + } + + @Test void nestedRollbackDiscardsCommitHooksButCompletesOnceAtPhysicalEnd() throws Exception { + final DataSource pool = pool(); + final List events = new ArrayList(); + try { + Transactions.execute(() -> { + Transactions.registerSynchronization(hook(events, "outer")); + pool.execute("INSERT INTO t VALUES (1)", null); + assertThrows(IOException.class, () -> Transactions.execute(Propagation.NESTED, false, -1, Isolation.DEFAULT, () -> { + Transactions.bindResource("nested", "retained"); + Transactions.registerSynchronization(hook(events, "nested")); + pool.execute("INSERT INTO t VALUES (2)", null); + throw new IOException("undo savepoint"); + })); + assertTrue(events.isEmpty()); + assertEquals("retained", Transactions.getResource("nested")); + return null; + }); + assertEquals(Arrays.asList("outer:before:false", "outer:commit", "outer:COMMITTED", "nested:COMMITTED"), events); + assertEquals(1, ((Number) pool.queryOne("SELECT COUNT(*) AS n FROM t", null).get("n")).intValue()); + } finally { pool.close(); } + } + + @Test void lazySavepointRollbackAlsoDiscardsCommitHooks() throws Exception { + final List events = new ArrayList(); + Transactions.execute(() -> { + Transactions.execute(Propagation.NESTED, false, -1, Isolation.DEFAULT, () -> { + Transactions.registerSynchronization(hook(events, "nested")); + Transactions.setRollbackOnly(); + return null; + }); + return null; + }); + assertEquals(Arrays.asList("nested:COMMITTED"), events); + } + + @Test void beforeCommitFailureRollsBackAndCleansResources() throws Exception { + final DataSource pool = pool(); + final List events = new ArrayList(); + try { + assertThrows(TransactionException.class, () -> Transactions.execute(() -> { + pool.execute("INSERT INTO t VALUES (1)", null); + Transactions.bindResource("key", "value"); + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { throw new IllegalArgumentException("before"); } + }); + Transactions.registerSynchronization(hook(events, "later")); + return null; + })); + assertEquals(Arrays.asList("later:ROLLED_BACK"), events); + assertNull(Transactions.getResource("key")); + assertFalse(Transactions.isActive()); + assertEquals(0, ((Number) pool.queryOne("SELECT COUNT(*) AS n FROM t", null).get("n")).intValue()); + } finally { pool.close(); } + } + + @Test void beforeCommitErrorIsRethrownAfterRollbackAndCleanup() throws Exception { + final DataSource pool = pool(); + final List events = new ArrayList(); + final AssertionError failure = new AssertionError("before"); + try { + assertSame(failure, assertThrows(AssertionError.class, () -> Transactions.execute(() -> { + pool.execute("INSERT INTO t VALUES (1)", null); + Transactions.bindResource("key", "value"); + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { throw failure; } + }); + Transactions.registerSynchronization(hook(events, "later")); + return null; + }))); + assertEquals(Arrays.asList("later:ROLLED_BACK"), events); + assertNull(Transactions.getResource("key")); + assertFalse(Transactions.isActive()); + assertEquals(0, ((Number) pool.queryOne("SELECT COUNT(*) AS n FROM t", null).get("n")).intValue()); + } finally { pool.close(); } + } + + @Test void afterCommitAndCompletionErrorsCannotPreventLaterCleanup() throws Exception { + final List events = new ArrayList(); + TransactionException failure = assertThrows(TransactionException.class, () -> Transactions.execute(() -> { + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void afterCommit() { throw new AssertionError("after"); } + public void afterCompletion(Transactions.Outcome outcome) { throw new AssertionError("cleanup"); } + }); + Transactions.registerSynchronization(hook(events, "later")); + Transactions.bindResource("key", "value"); + return null; + })); + assertTrue(failure.getMessage().contains("committed")); + assertEquals(Arrays.asList("later:before:false", "later:commit", "later:COMMITTED"), events); + assertNull(Transactions.getResource("key")); + assertFalse(Transactions.isActive()); + } + + @Test void commitFailureNeverRunsAfterCommitAndDiscardsConnection() throws Exception { + final DataSource pool = pool(); + final List events = new ArrayList(); + try { + pool.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)", null); + pool.execute("CREATE TABLE child (id INTEGER REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED)", null); + final Database[] borrowed = new Database[1]; + assertThrows(TransactionException.class, () -> Transactions.execute(() -> { + borrowed[0] = pool.borrow(); + pool.execute("INSERT INTO child VALUES (5)", null); + Transactions.registerSynchronization(hook(events, "tx")); + return null; + })); + assertEquals(Arrays.asList("tx:before:false", "tx:UNKNOWN"), events); + Database next = pool.borrow(); + assertNotSame(borrowed[0], next); + assertFalse(next.isInTransaction()); + pool.release(next); + assertEquals(0, ((Number) pool.queryOne("SELECT COUNT(*) AS n FROM child", null).get("n")).intValue()); + } finally { pool.close(); } + } + + @Test void explicitIsolationRejectsIncompatibleJoinsAndNonTransactions() throws Exception { + Transactions.execute(Propagation.REQUIRED, false, -1, Isolation.SERIALIZABLE, () -> { + Transactions.execute(Propagation.REQUIRED, false, -1, Isolation.SERIALIZABLE, () -> null); + for (final Propagation propagation : new Propagation[] {Propagation.REQUIRED, Propagation.NESTED, Propagation.SUPPORTS}) { + assertThrows(TransactionException.IllegalState.class, () -> Transactions.execute(propagation, + false, -1, Isolation.READ_COMMITTED, () -> fail("body must not run"))); + } + return null; + }); + assertThrows(TransactionException.IllegalState.class, () -> Transactions.execute(Propagation.SUPPORTS, + false, -1, Isolation.SERIALIZABLE, () -> null)); + Transactions.execute(() -> { + assertThrows(TransactionException.IllegalState.class, () -> Transactions.execute(Propagation.REQUIRED, + false, -1, Isolation.SERIALIZABLE, () -> null)); + return null; + }); + } + + @Test void sqliteIsolationAndPoolReuseAfterCommitRollbackAndUnsupportedRequest() throws Exception { + final DataSource pool = pool(); + try { + Database first = pool.borrow(); + pool.release(first); + pool.inTransaction(Isolation.SERIALIZABLE, db -> { db.execute("INSERT INTO t VALUES (1)", null); return null; }); + assertThrows(IOException.class, () -> pool.inTransaction(Isolation.SERIALIZABLE, db -> { throw new IOException("rollback"); })); + assertThrows(IOException.class, () -> pool.inTransaction(Isolation.READ_COMMITTED, db -> fail("unsupported"))); + Database next = pool.borrow(); + assertSame(first, next); + assertFalse(next.isInTransaction()); + assertEquals(0, ((Number) next.queryOne("PRAGMA read_uncommitted", null).get("read_uncommitted")).intValue()); + next.execute("PRAGMA read_uncommitted = 1", null); + assertThrows(IOException.class, () -> next.beginTransaction(false, Isolation.SERIALIZABLE)); + assertEquals(1, ((Number) next.queryOne("PRAGMA read_uncommitted", null).get("read_uncommitted")).intValue()); + pool.release(next); + } finally { pool.close(); } + } + + @Test void afterCommitRequiresNewCanUseSingleConnectionPool() throws Exception { + final DataSource pool = pool(); + try { + Transactions.execute(() -> { + pool.execute("INSERT INTO t VALUES (1)", null); + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void afterCommit() { + assertThrows(TransactionException.IllegalState.class, () -> pool.query("SELECT * FROM t", null)); + try { + Transactions.execute(Propagation.REQUIRES_NEW, false, -1, Isolation.DEFAULT, () -> { + pool.execute("INSERT INTO t VALUES (2)", null); + return null; + }); + } catch (Exception e) { throw new RuntimeException(e); } + } + }); + return null; + }); + assertEquals(2, ((Number) pool.queryOne("SELECT COUNT(*) AS n FROM t", null).get("n")).intValue()); + } finally { pool.close(); } + } + + @Test void beforeCommitCanWriteButCannotRegisterMoreHooks() throws Exception { + final DataSource pool = pool(); + try { + Transactions.execute(() -> { + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { + assertThrows(TransactionException.IllegalState.class, () -> Transactions.registerSynchronization(this)); + try { pool.execute("INSERT INTO t VALUES (1)", null); } + catch (IOException e) { throw new RuntimeException(e); } + } + }); + return null; + }); + assertEquals(1, ((Number) pool.queryOne("SELECT COUNT(*) AS n FROM t", null).get("n")).intValue()); + } finally { pool.close(); } + } + + @Test void joinedRollbackCompletesExactlyOnceEvenWhenCompletionCalledAgain() { + final List events = new ArrayList(); + Transactions.Transaction tx = Transactions.begin(Transactions.REQUIRED, false, -1); + Transactions.registerSynchronization(hook(events, "tx")); + Transactions.Transaction joined = Transactions.begin(Transactions.REQUIRED, false, -1); + Transactions.afterThrow(joined, true); + assertThrows(TransactionException.UnexpectedRollback.class, () -> Transactions.commit(tx)); + Transactions.commit(tx); + Transactions.afterThrow(tx, true); + assertEquals(Arrays.asList("tx:ROLLED_BACK"), events); + } + @Test void failedRequiresNewRestoresOuterAndDoesNotRunItsCommitHooks() throws Exception { + final List events = new ArrayList(); + Transactions.execute(() -> { + Transactions.bindResource("key", "outer"); + assertThrows(IllegalArgumentException.class, () -> Transactions.execute(Propagation.REQUIRES_NEW, + false, -1, Isolation.DEFAULT, () -> { + Transactions.bindResource("key", "inner"); + Transactions.registerSynchronization(hook(events, "inner")); + throw new IllegalArgumentException("inner body"); + })); + assertEquals("outer", Transactions.getResource("key")); + Transactions.registerSynchronization(hook(events, "outer")); + return null; + }); + assertEquals(Arrays.asList("inner:ROLLED_BACK", "outer:before:false", "outer:commit", "outer:COMMITTED"), events); + } + + @Test void successfulNestedAndRequiredHooksWaitForOuterCommit() throws Exception { + final List events = new ArrayList(); + Transactions.execute(() -> { + for (final Propagation propagation : new Propagation[] {Propagation.REQUIRED, Propagation.NESTED}) { + Transactions.execute(propagation, false, -1, Isolation.DEFAULT, () -> { + Transactions.registerSynchronization(hook(events, propagation.name())); + return null; + }); + assertTrue(events.isEmpty()); + } + return null; + }); + assertEquals(Arrays.asList("REQUIRED:before:false", "NESTED:before:false", "REQUIRED:commit", "NESTED:commit", + "REQUIRED:COMMITTED", "NESTED:COMMITTED"), events); + } + + @Test void rollbackFailureStillCompletesExactlyOnceAndDiscardsConnection() throws Exception { + final DataSource pool = pool(); + final List events = new ArrayList(); + final Database[] borrowed = new Database[1]; + try { + assertThrows(IOException.class, () -> Transactions.execute(() -> { + borrowed[0] = pool.borrow(); + // End the engine transaction behind the facade to make its ROLLBACK fail. + borrowed[0].execute("ROLLBACK", null); + Transactions.registerSynchronization(hook(events, "tx")); + throw new IOException("body failure stays primary"); + })); + assertEquals(Arrays.asList("tx:UNKNOWN"), events); + Database next = pool.borrow(); + assertNotSame(borrowed[0], next); + pool.release(next); + assertFalse(Transactions.isActive()); + } finally { pool.close(); } + } + + @Test void beforeHookRollbackOnlySkipsCommitAndLaterCleanupStillRuns() throws Exception { + final List events = new ArrayList(); + Transactions.execute(() -> { + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { Transactions.setRollbackOnly(); } + }); + Transactions.registerSynchronization(hook(events, "tx")); + return null; + }); + assertEquals(Arrays.asList("tx:before:false", "tx:ROLLED_BACK"), events); + } + + @Test void timeoutDuringBeforeCommitRunsCompletionWithoutAfterCommit() { + final List events = new ArrayList(); + assertThrows(TransactionException.TimedOut.class, () -> Transactions.execute(Propagation.REQUIRED, + false, 1, Isolation.DEFAULT, () -> { + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { + try { Thread.sleep(1100); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } + } + }); + Transactions.registerSynchronization(hook(events, "tx")); + return null; + })); + assertEquals(Arrays.asList("tx:before:false", "tx:ROLLED_BACK"), events); + assertFalse(Transactions.isActive()); + } + + @Test void caughtBeginFailureStillPreventsCommitCallbacks() throws Exception { + final DataSource pool = pool(); + final List events = new ArrayList(); + try { + assertThrows(TransactionException.UnexpectedRollback.class, () -> Transactions.execute(Propagation.REQUIRED, + false, -1, Isolation.READ_COMMITTED, () -> { + Transactions.registerSynchronization(hook(events, "tx")); + assertThrows(IOException.class, () -> pool.query("SELECT * FROM t", null)); + return null; + })); + assertEquals(Arrays.asList("tx:ROLLED_BACK"), events); + assertFalse(Transactions.isActive()); + } finally { pool.close(); } + } + + @Test void caughtClosedPoolFailurePreventsCommitCallbacks() throws Exception { + DataSource pool = pool(); + pool.close(); + assertCaughtBorrowFailureRollsBack(pool); + } + + @Test void caughtPoolExhaustionPreventsCommitCallbacksAndPreservesBorrowedConnection() throws Exception { + DataSource pool = DataSource.open(new File(dir, "exhausted.db").getAbsolutePath(), 1, 1000, 25); + Database held = pool.borrow(); + try { + assertCaughtBorrowFailureRollsBack(pool); + pool.release(held); + Database next = pool.borrow(); + assertSame(held, next); + assertFalse(next.isInTransaction()); + pool.release(next); + } finally { pool.close(); } + } + + @Test void caughtReplacementOpenFailurePreventsCommitCallbacks() throws Exception { + DataSource pool = pool(); + try { + Database first = pool.borrow(); + first.close(); + pool.release(first); + File database = new File(dir, "sync.db"); + assertTrue(database.delete()); + assertTrue(database.mkdir()); // SQLite cannot open a directory as its database. + assertCaughtBorrowFailureRollsBack(pool); + } finally { pool.close(); } + } + + private void assertCaughtBorrowFailureRollsBack(final DataSource pool) throws Exception { + final List events = new ArrayList(); + final Object[] resourceAtCompletion = new Object[1]; + Transactions.execute(() -> { + Transactions.bindResource("scope", "outer"); + assertThrows(TransactionException.UnexpectedRollback.class, () -> Transactions.execute(Propagation.REQUIRES_NEW, + false, -1, Isolation.DEFAULT, () -> { + Transactions.bindResource("scope", "inner"); + Transactions.registerSynchronization(hook(events, "tx")); + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void afterCompletion(Transactions.Outcome outcome) { + resourceAtCompletion[0] = Transactions.getResource("scope"); + } + }); + assertThrows(IOException.class, () -> pool.query("SELECT 1", null)); + return null; // The application caught the acquisition failure. + })); + assertTrue(Transactions.isActive()); + assertFalse(Transactions.isRollbackOnly()); + assertEquals("outer", Transactions.getResource("scope")); + return null; + }); + assertEquals(Arrays.asList("tx:ROLLED_BACK"), events); + assertEquals("inner", resourceAtCompletion[0]); + assertNull(Transactions.getResource("scope")); + assertFalse(Transactions.isActive()); + } + +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java index 8876b8af136..81c22f5fc9e 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -134,7 +134,9 @@ private void transaction(StringBuilder sb, BackendBeans.Aspect aspect, String in sb.append(" Object cn1Tx = com.codename1.impl.backend.BackendAccess.get().begin(") .append("com.codename1.impl.backend.BackendAccess.").append(propagation).append(", ") .append(tx.getBoolOrDefault("readOnly", false)).append(", ") - .append(tx.getIntOrDefault("timeout", -1)).append(");\n"); + .append(tx.getIntOrDefault("timeout", -1)).append(", ") + .append("com.codename1.backend.annotations.Isolation.") + .append(BackendBeans.enumName(tx.get("isolation"), "DEFAULT")).append(");\n"); if (!isVoid) { sb.append(" ").append(retType).append(" cn1Result;\n"); } diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index ad5f0c305a4..927de3fd9d7 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -1763,6 +1763,27 @@ public void aFailedStatementRollsBackAndAnotherCheckedExceptionCommits() throws } } + @Test + public void declarativeIsolationIsWovenAndMethodOverridesClassDefault() throws Exception { + Map sources = new LinkedHashMap(); + sources.put("com.example.Isolated", PKG + + "@Component @Transactional(isolation = Isolation.SERIALIZABLE) public class Isolated {\n" + + " public String outer() {\n" + + " try { inner(); return \"wrong\"; }\n" + + " catch (TransactionException.IllegalState expected) { return \"checked\"; }\n" + + " }\n" + + " @Transactional(isolation = Isolation.READ_COMMITTED) public void inner() { }\n" + + "}\n"); + File classes = compile(sources); + assertNoErrors(process(classes)); + try (URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader())) { + Object bean = loader.loadClass("com.example.Isolated").newInstance(); + assertEquals("checked", bean.getClass().getMethod("outer").invoke(bean)); + } + assertFalse(com.codename1.backend.Transactions.isActive()); + } + @Test public void duplicateToolAndManagedNamesAreBuildErrors() throws Exception { Map s = new LinkedHashMap(); diff --git a/vm/backend/demo/txcheck/README.md b/vm/backend/demo/txcheck/README.md new file mode 100644 index 00000000000..a0074fef032 --- /dev/null +++ b/vm/backend/demo/txcheck/README.md @@ -0,0 +1,25 @@ +# Transaction completion and isolation probe + +Run this probe locally on Java 8 and ParparVM. It covers completion ordering, +resource suspension, nested rollback, callback failures, commit failure, and +connection reuse. SQLite is the default. Set `CN1_TXCHECK_URL` to an isolated +PostgreSQL or MySQL database to exercise that engine; the probe creates/drops +`cn1_sync_*` tables. Commit-failure checks use deferred constraints and run only +on SQLite and PostgreSQL. + +From the repository root on macOS: + +```sh +export JAVA_HOME=$(/usr/libexec/java_home -v 1.8) +export JDK_8_HOME="$JAVA_HOME" +export PATH="$JAVA_HOME/bin:$PATH" +CN1_BACKEND_DEMO=demo/txcheck CN1_BACKEND_JAVA="$JAVA_HOME" \ + vm/backend/run-javase.sh com.demo.TransactionCheck +CN1_BACKEND_DEMO=demo/txcheck CN1_BACKEND_STANDALONE_DEMO=1 \ + vm/backend/build.sh TransactionCheck com.demo /tmp/cn1-transaction-check +/tmp/cn1-transaction-check +``` + +`run-javase.sh` uses the existing backend demo contract generation prerequisites. +Both runs must print `TXCHECK OK` with the same check count for the same database. +No hosted native-test job is needed or permitted. diff --git a/vm/backend/demo/txcheck/com/demo/TransactionCheck.java b/vm/backend/demo/txcheck/com/demo/TransactionCheck.java new file mode 100644 index 00000000000..6ed89a9c95b --- /dev/null +++ b/vm/backend/demo/txcheck/com/demo/TransactionCheck.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.demo; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Database; +import com.codename1.backend.Transactions; +import com.codename1.backend.TransactionException; +import com.codename1.backend.annotations.Isolation; +import com.codename1.backend.annotations.Propagation; +import java.io.IOException; + +/// Standalone JVM/ParparVM transaction synchronization and isolation probe. +/// CN1_TXCHECK_URL selects an optional PostgreSQL/MySQL database; SQLite is default. +public class TransactionCheck { + private static final StringBuilder events = new StringBuilder(); + private static int passed; + + public static void main(String[] args) throws Exception { + String url = System.getenv("CN1_TXCHECK_URL"); + if (url == null || url.length() == 0) { + url = ":memory:"; + } + final DataSource pool = DataSource.open(url, 1, 1000, 10000); + try { + pool.execute("DROP TABLE IF EXISTS cn1_sync_probe", null); + pool.execute("CREATE TABLE cn1_sync_probe (v INTEGER)", null); + final Database first = pool.borrow(); + final String engine = first.dialect().getName(); + pool.release(first); + Transactions.execute(Propagation.REQUIRED, false, -1, Isolation.SERIALIZABLE, new Transactions.Work() { + public Object run() throws Exception { + Transactions.bindResource("key", "outer"); + Transactions.registerSynchronization(hook("outer")); + Transactions.execute(Propagation.REQUIRES_NEW, true, -1, Isolation.SERIALIZABLE, new Transactions.Work() { + public Object run() throws Exception { + check(Transactions.getResource("key") == null, "independent resources"); + Transactions.bindResource("key", "inner"); + Transactions.registerSynchronization(hook("inner")); + pool.query("SELECT * FROM cn1_sync_probe", null); + return null; + } + }); + check("outer".equals(Transactions.getResource("key")), "suspended resources restored"); + pool.execute("INSERT INTO cn1_sync_probe VALUES (1)", null); + Transactions.execute(Propagation.NESTED, false, -1, Isolation.SERIALIZABLE, new Transactions.Work() { + public Object run() throws Exception { + Transactions.registerSynchronization(hook("nested")); + pool.execute("INSERT INTO cn1_sync_probe VALUES (2)", null); + Transactions.setRollbackOnly(); + return null; + } + }); + return null; + } + }); + check(events.toString().equals("inner:before:true;inner:commit;inner:COMMITTED;outer:before:false;outer:commit;outer:COMMITTED;nested:COMMITTED;"), "ordered hooks and savepoint rollback"); + check(((Number) pool.queryOne("SELECT COUNT(*) AS n FROM cn1_sync_probe", null).get("n")).intValue() == 1, "savepoint data rolled back"); + check(!Transactions.isActive() && Transactions.getResource("key") == null, "scope cleared"); + Database reused = pool.borrow(); + check(first == reused && !reused.isInTransaction(), "same connection reused cleanly"); + pool.release(reused); + + events.setLength(0); + try { + Transactions.execute(new Transactions.Work() { + public Object run() throws Exception { + Transactions.registerSynchronization(hook("rollback")); + pool.execute("INSERT INTO cn1_sync_probe VALUES (3)", null); + throw new IOException("body failed"); + } + }); + throw new RuntimeException("body failure was swallowed"); + } catch (IOException expected) { } + check(events.toString().equals("rollback:ROLLED_BACK;"), "rollback completion only"); + + events.setLength(0); + try { + Transactions.execute(new Transactions.Work() { + public Object run() throws Exception { + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { throw new IllegalArgumentException("before"); } + }); + Transactions.registerSynchronization(hook("before-failed")); + pool.execute("INSERT INTO cn1_sync_probe VALUES (4)", null); + return null; + } + }); + throw new RuntimeException("before failure was swallowed"); + } catch (TransactionException expected) { } + check(events.toString().equals("before-failed:ROLLED_BACK;"), "before failure cleanup"); + + events.setLength(0); + try { + Transactions.execute(new Transactions.Work() { + public Object run() { + Transactions.registerSynchronization(new Transactions.Synchronization() { + public void afterCommit() { throw new IllegalArgumentException("after"); } + public void afterCompletion(Transactions.Outcome outcome) { throw new IllegalArgumentException("cleanup"); } + }); + Transactions.registerSynchronization(hook("after-failed")); + return null; + } + }); + throw new RuntimeException("after failure was swallowed"); + } catch (TransactionException expected) { } + check(events.toString().equals("after-failed:before:false;after-failed:commit;after-failed:COMMITTED;"), "callback errors preserve remaining hooks"); + check(!Transactions.isActive(), "callback failure clears thread"); + + if ("sqlite".equals(engine)) { + try { + pool.inTransaction(Isolation.READ_COMMITTED, new DataSource.Work() { + public Object run(Database db) { throw new RuntimeException("unsupported isolation accepted"); } + }); + throw new RuntimeException("unsupported isolation did not throw"); + } catch (IOException expected) { passed++; } + } else if ("postgres".equals(engine) || "postgresql".equals(engine)) { + final String baseline = String.valueOf(pool.queryOne("SHOW transaction_isolation", null).get("transaction_isolation")); + pool.inTransaction(Isolation.SERIALIZABLE, new DataSource.Work() { + public Object run(Database db) throws Exception { + check("serializable".equals(db.queryOne("SHOW transaction_isolation", null).get("transaction_isolation")), "PostgreSQL isolation applied"); + return null; + } + }); + check(baseline.equals(pool.queryOne("SHOW transaction_isolation", null).get("transaction_isolation")), "PostgreSQL default restored"); + } + + // A deferred constraint makes COMMIT fail on both SQLite and PostgreSQL. + if (!"mysql".equals(engine)) { + pool.execute("DROP TABLE IF EXISTS cn1_sync_child", null); + pool.execute("DROP TABLE IF EXISTS cn1_sync_parent", null); + pool.execute("CREATE TABLE cn1_sync_parent (id INTEGER PRIMARY KEY)", null); + pool.execute("CREATE TABLE cn1_sync_child (id INTEGER REFERENCES cn1_sync_parent(id) DEFERRABLE INITIALLY DEFERRED)", null); + events.setLength(0); + try { + Transactions.execute(new Transactions.Work() { + public Object run() throws Exception { + Transactions.registerSynchronization(hook("commit-failed")); + pool.execute("INSERT INTO cn1_sync_child VALUES (123)", null); + return null; + } + }); + throw new RuntimeException("failed commit accepted"); + } catch (TransactionException expected) { } + check(events.toString().equals("commit-failed:before:false;commit-failed:UNKNOWN;"), "failed commit skips after-commit"); + check(!Transactions.isActive(), "failed commit clears thread"); + } + caughtBorrowFailureRollsBack(); + System.out.println("TXCHECK OK passed=" + passed); + } finally { + pool.close(); + } + } + + private static void caughtBorrowFailureRollsBack() throws Exception { + final DataSource closed = DataSource.open(":memory:"); + closed.close(); + events.setLength(0); + try { + Transactions.execute(new Transactions.Work() { + public Object run() throws Exception { + Transactions.bindResource("failed-borrow", "resource"); + Transactions.registerSynchronization(hook("borrow-failed")); + try { + closed.query("SELECT 1", null); + throw new RuntimeException("closed pool allowed a query"); + } catch (IOException expected) { } + return null; + } + }); + throw new RuntimeException("caught borrow failure committed"); + } catch (TransactionException.UnexpectedRollback expected) { } + check(events.toString().equals("borrow-failed:ROLLED_BACK;"), "borrow failure skips commit hooks"); + check(!Transactions.isActive() && Transactions.getResource("failed-borrow") == null, + "borrow failure clears transaction and resources"); + } + + private static Transactions.Synchronization hook(final String name) { + return new Transactions.Synchronization() { + public void beforeCommit(boolean readOnly) { events.append(name).append(":before:").append(readOnly).append(';'); } + public void afterCommit() { events.append(name).append(":commit;"); } + public void afterCompletion(Transactions.Outcome outcome) { events.append(name).append(':').append(outcome).append(';'); } + }; + } + + private static void check(boolean condition, String message) { + if (!condition) { throw new RuntimeException(message + " events=" + events); } + passed++; + } +} diff --git a/vm/backend/src/com/codename1/backend/Access.java b/vm/backend/src/com/codename1/backend/Access.java index 49d15d101d1..ec9d94aa006 100644 --- a/vm/backend/src/com/codename1/backend/Access.java +++ b/vm/backend/src/com/codename1/backend/Access.java @@ -287,6 +287,12 @@ public Object begin(int propagation, boolean readOnly, int timeoutSeconds) { return Transactions.begin(propagation, readOnly, timeoutSeconds); } + @Override + public Object begin(int propagation, boolean readOnly, int timeoutSeconds, + com.codename1.backend.annotations.Isolation isolation) { + return Transactions.begin(propagation, readOnly, timeoutSeconds, isolation); + } + @Override public void commit(Object transaction) { if (transaction instanceof Transactions.Transaction) { diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index ac57cc1662e..fa46172d3ad 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -22,6 +22,8 @@ */ package com.codename1.backend; +import com.codename1.backend.annotations.Isolation; +import com.codename1.backend.annotations.Propagation; import com.codename1.impl.backend.AsyncTask; import java.io.IOException; @@ -429,6 +431,19 @@ public Object inTransaction(Work body) throws Exception { return withConnection(new TransactionWork(body)); } + /// Runs a REQUIRED thread-bound transaction with explicit isolation. Pool + /// operations in the body join it, and transaction callbacks/resources are available. + public Object inTransaction(final Isolation isolation, + final Work body) throws Exception { + return Transactions.execute(Propagation.REQUIRED, + false, -1, isolation, new Transactions.Work() { + @Override + public Object run() throws Exception { + return body.run(borrow()); + } + }); + } + /// [#inTransaction]'s body, run inside a transaction on the borrowed /// connection. private static final class TransactionWork implements Work { diff --git a/vm/backend/src/com/codename1/backend/Database.java b/vm/backend/src/com/codename1/backend/Database.java index 8674c3fe9bc..5f17413dddb 100644 --- a/vm/backend/src/com/codename1/backend/Database.java +++ b/vm/backend/src/com/codename1/backend/Database.java @@ -22,6 +22,8 @@ */ package com.codename1.backend; +import com.codename1.backend.annotations.Isolation; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -917,6 +919,59 @@ public void beginTransaction(boolean readOnly) throws IOException { } } + /// Begins a transaction with explicit isolation. Settings are transaction-local, + /// so commit/rollback restore the connection's defaults without session SETs. + /// SQLite accepts only SERIALIZABLE and refuses a connection configured with + /// read_uncommitted. PostgreSQL refuses READ_UNCOMMITTED instead of upgrading it. + public void beginTransaction(boolean readOnly, + Isolation isolation) throws IOException { + if (isolation == null) { + throw new IllegalArgumentException("Isolation is required"); + } + enter(); + try { + awaitTransactionOwner(); + if (managedTransaction) { + throw new IOException("Transaction already active"); + } + if (isolation == Isolation.DEFAULT) { + beginTransaction(readOnly); + return; + } + if (sqlite != null) { + if (isolation != Isolation.SERIALIZABLE) { + throw new IOException("SQLite supports only SERIALIZABLE isolation"); + } + Map row = queryOne("PRAGMA read_uncommitted", null); + if (((Number) row.get("read_uncommitted")).intValue() != 0) { + throw new IOException("SERIALIZABLE is incompatible with SQLite PRAGMA read_uncommitted"); + } + beginTransaction(readOnly); + return; + } + if (mysql != null) { + try { + mysql.begin(readOnly, isolation); + } catch (IOException err) { + // A failed START may leave the next-transaction setting armed. + // Discard the connection rather than lend that setting to another caller. + close(); + throw err; + } + } else { + if (isolation == Isolation.READ_UNCOMMITTED) { + throw new IOException("PostgreSQL does not provide READ_UNCOMMITTED isolation"); + } + execute("BEGIN ISOLATION LEVEL " + isolation.name().replace('_', ' ') + + (readOnly ? " READ ONLY" : " READ WRITE"), null); + } + managedTransaction = true; + transactionOwner = caller(); + } finally { + exit(); + } + } + /// Marks a savepoint inside the open transaction, which /// [#rollbackToSavepoint] can return to without ending the transaction. /// The name must be a plain identifier; it is written into the statement. @@ -993,9 +1048,15 @@ public void rollbackTransaction() throws IOException { } public Object transaction(Work body) throws Exception { + return transaction(Isolation.DEFAULT, body); + } + + /// Runs a connection-local transaction at the requested isolation. For scoped + /// resources and synchronization hooks, use Transactions.execute instead. + public Object transaction(Isolation isolation, Work body) throws Exception { enter(); try { - beginTransaction(); + beginTransaction(false, isolation); boolean committed = false; try { Object result = body.run(this); diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index a7c1ddfcd5c..f93cea7bf76 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -23,6 +23,8 @@ package com.codename1.backend; import java.io.IOException; +import com.codename1.backend.annotations.Isolation; +import com.codename1.backend.annotations.Propagation; import com.codename1.backend.orm.EntityManager; @@ -32,10 +34,9 @@ /// The build rewrites every `@Transactional` method into a call to /// [#begin], the original body, then [#commit] -- or /// [#afterThrow] with the rollback decision it worked out from the -/// annotation. The propagation, the read-only flag and the timeout arrive as -/// constants, so this class interprets nothing: it holds the one piece of state -/// that has to exist at run time, which is which connection the thread's -/// transaction is on. +/// annotation. Propagation, isolation, read-only and timeout arrive as constants. +/// This class binds the connection, completion hooks and scoped resources to +/// the physical transaction on the calling thread. /// /// ## Joining /// @@ -45,7 +46,7 @@ /// [EntityManager]'s daos and a managed session all join it. The connection /// is borrowed, and BEGIN sent, only when the transaction first needs it: a /// `@Transactional` method that returns before touching the database costs -/// a thread-local write and nothing else. +/// only an in-memory scope, with no database round trip. /// /// A thread that has never begun a transaction does not even read the /// thread-local: [#used] is set by the first [#begin] of the process, @@ -84,11 +85,114 @@ public final class Transactions { private Transactions() { } + /// Final outcome. UNKNOWN means a commit or rollback failed and durability + /// cannot be established; it must never be treated as a successful commit. + public enum Outcome { COMMITTED, ROLLED_BACK, UNKNOWN } + + /// Hooks run synchronously in registration order, once per physical + /// transaction (including a scope that never borrowed a connection). + /// Override only the hooks needed. Before-commit failures prevent commit; + /// after-commit failures are reported after all completion hooks run. + /// Completion failures are logged and do not replace the original failure. + public abstract static class Synchronization { + public void beforeCommit(boolean readOnly) { } + public void afterCommit() { } + public void afterCompletion(Outcome outcome) { } + } + + /// A programmatic transaction body. Any exception or Error rolls it back. + public interface Work { + Object run() throws Exception; + } + + /// Runs a REQUIRED transaction with the engine's default isolation. + public static Object execute(Work work) throws Exception { + return execute(Propagation.REQUIRED, false, -1, Isolation.DEFAULT, work); + } + + /// Runs a transaction with the same propagation and isolation rules as + /// `@Transactional`. Unlike annotation rollback rules, every body failure rolls back. + public static Object execute(Propagation propagation, boolean readOnly, int timeoutSeconds, + Isolation isolation, Work work) throws Exception { + if (propagation == null || work == null) { + throw new IllegalArgumentException("Propagation and work are required"); + } + Transaction tx = begin(propagation.ordinal(), readOnly, timeoutSeconds, isolation); + Object result; + try { + result = work.run(); + } catch (Exception err) { + afterThrow(tx, true); + throw err; + } catch (Error err) { + afterThrow(tx, true); + throw err; + } + commit(tx); + return result; + } + + /// Registers hooks on the current physical transaction. Registration is + /// closed when completion begins. A rolled-back NESTED scope suppresses its + /// before/after-commit hooks, but its completion hook still runs at physical end. + public static void registerSynchronization(Synchronization synchronization) { + Physical p = required(); + if (synchronization == null || p.completing) { + throw new TransactionException.IllegalState("Cannot register synchronization during completion or null"); + } + p.synchronizations.add(new Registration(synchronization)); + } + + /// Binds a non-null value to a non-null key; duplicate bindings are refused. + /// Bindings belong to the physical transaction, not to a savepoint. They are + /// cleared after all completion hooks; dispose values in afterCompletion. + public static void bindResource(Object key, Object value) { + Physical p = required(); + if (key == null || value == null || p.completed || p.resources.containsKey(key)) { + throw new TransactionException.IllegalState("Invalid or already bound transaction resource"); + } + p.resources.put(key, value); + } + + /// Returns the current binding, or null outside a transaction or for a missing key. + /// Bindings remain visible to completion callbacks until all have finished. + public static Object getResource(Object key) { + Physical p = used ? (Physical) CURRENT.get() : null; + return p == null ? null : p.resources.get(key); + } + + /// Removes a binding, returning its previous value (or null). + public static Object unbindResource(Object key) { + return required().resources.remove(key); + } + + private static Physical required() { + Physical p = used ? (Physical) CURRENT.get() : null; + if (p == null) { + throw new TransactionException.IllegalState("No transaction is active"); + } + return p; + } + + private static final class Registration { + final Synchronization synchronization; + boolean commitEligible = true; + + Registration(Synchronization synchronization) { + this.synchronization = synchronization; + } + } + /// One database transaction, which any number of joined methods share. static final class Physical { DataSource pool; Database db; final boolean readOnly; + final Isolation isolation; + final java.util.List synchronizations = new java.util.ArrayList(); + final java.util.Map resources = new java.util.HashMap(); + boolean completing; + boolean completed; final long deadline; /// Set by a participant that failed: the outer commit must refuse loudly. boolean rollbackOnly; @@ -109,9 +213,10 @@ static final class Physical { java.util.List pendingSavepoints; com.codename1.orm.session.Session session; - Physical(DataSource pool, boolean readOnly, int timeoutSeconds) { + Physical(DataSource pool, boolean readOnly, int timeoutSeconds, Isolation isolation) { this.pool = pool; this.readOnly = readOnly; + this.isolation = isolation; this.deadline = timeoutSeconds > 0 ? System.currentTimeMillis() + timeoutSeconds * 1000L : 0; } @@ -128,6 +233,7 @@ static final class Transaction { final boolean suspends; final String savepoint; boolean completed; + final int synchronizationStart; /// A NESTED call asked, by setRollbackOnly(), to undo its own work. boolean rollbackRequested; @@ -138,6 +244,7 @@ static final class Transaction { this.suspended = suspended; this.suspends = suspends; this.savepoint = savepoint; + this.synchronizationStart = physical == null ? 0 : physical.synchronizations.size(); } /// Whether this call began the physical transaction it runs in. @@ -148,13 +255,14 @@ boolean isNewTransaction() { /// Whether the calling thread is inside a transaction. public static boolean isActive() { - return used && CURRENT.get() != null; + Physical p = used ? (Physical) CURRENT.get() : null; + return p != null && !p.completed; } /// Whether the calling thread's transaction has been marked rollback-only. public static boolean isRollbackOnly() { Physical p = used ? (Physical) CURRENT.get() : null; - if (p == null) { + if (p == null || p.completed) { return false; } Transaction top = innermost(p); @@ -170,7 +278,7 @@ private static Transaction innermost(Physical p) { /// return a value and still undo its work. public static void setRollbackOnly() { Physical p = used ? (Physical) CURRENT.get() : null; - if (p == null) { + if (p == null || p.completed) { throw new TransactionException.IllegalState("setRollbackOnly() outside a " + "transaction: there is nothing to roll back"); } @@ -201,16 +309,37 @@ public static void setRollbackOnly() { /// /// - `timeoutSeconds`: the annotation's timeout, zero or less for none static Transaction begin(int propagation, boolean readOnly, int timeoutSeconds) { + return begin(propagation, readOnly, timeoutSeconds, Isolation.DEFAULT); + } + + static Transaction begin(int propagation, boolean readOnly, int timeoutSeconds, Isolation isolation) { + if (isolation == null) { + throw new IllegalArgumentException("Isolation is required"); + } used = true; - Transaction tx = open(propagation, readOnly, timeoutSeconds); + Transaction tx = open(propagation, readOnly, timeoutSeconds, isolation); if (tx.physical != null && (tx.kind == KIND_JOINED || tx.kind == KIND_SAVEPOINT)) { tx.physical.calls.add(tx); } return tx; } - private static Transaction open(int propagation, boolean readOnly, int timeoutSeconds) { + private static Transaction open(int propagation, boolean readOnly, int timeoutSeconds, Isolation isolation) { Physical current = (Physical) CURRENT.get(); + if (current != null && propagation != REQUIRES_NEW && propagation != NOT_SUPPORTED + && propagation != NEVER) { + if (current.completed) { + throw new TransactionException.IllegalState("Transaction has completed; use REQUIRES_NEW for callback database work"); + } + if (isolation != Isolation.DEFAULT && isolation != current.isolation) { + throw new TransactionException.IllegalState("Isolation " + isolation + + " cannot join transaction with isolation " + current.isolation); + } + } + if (isolation != Isolation.DEFAULT && (propagation == NOT_SUPPORTED || propagation == NEVER + || (propagation == SUPPORTS && current == null))) { + throw new TransactionException.IllegalState("Isolation requires a physical transaction"); + } switch (propagation) { case SUPPORTS: return current == null ? new Transaction(KIND_NONE, null, null, false, null) @@ -232,23 +361,23 @@ private static Transaction open(int propagation, boolean readOnly, int timeoutSe CURRENT.set(null); return new Transaction(KIND_NONE, null, current, true, null); case REQUIRES_NEW: - return fresh(readOnly, timeoutSeconds, current, true); + return fresh(readOnly, timeoutSeconds, isolation, current, true); case NESTED: if (current == null) { - return fresh(readOnly, timeoutSeconds, null, false); + return fresh(readOnly, timeoutSeconds, isolation, null, false); } return nested(current); default: if (current != null) { return new Transaction(KIND_JOINED, current, null, false, null); } - return fresh(readOnly, timeoutSeconds, null, false); + return fresh(readOnly, timeoutSeconds, isolation, null, false); } } - private static Transaction fresh(boolean readOnly, int timeoutSeconds, Physical suspended, + private static Transaction fresh(boolean readOnly, int timeoutSeconds, Isolation isolation, Physical suspended, boolean suspends) { - Physical p = new Physical(null, readOnly, timeoutSeconds); + Physical p = new Physical(null, readOnly, timeoutSeconds, isolation); CURRENT.set(p); return new Transaction(KIND_NEW, p, suspended, suspends, null); } @@ -294,6 +423,9 @@ static void commit(Transaction tx) { leave(tx); switch (tx.kind) { case KIND_SAVEPOINT: + if (tx.rollbackRequested) { + discardNestedCommitHooks(tx); + } if (dropPending(tx)) { // Never set: the nested method did not touch the database. return; @@ -342,7 +474,7 @@ static void afterThrow(Transaction tx, boolean rollback) { if (!rollback) { try { commit(tx); - } catch (RuntimeException err) { + } catch (Throwable err) { System.err.println("A transaction ended by a checked exception could not " + "commit: " + err); } @@ -355,6 +487,7 @@ static void afterThrow(Transaction tx, boolean rollback) { tx.physical.rollbackOnly = true; return; case KIND_SAVEPOINT: + discardNestedCommitHooks(tx); if (dropPending(tx)) { // Never set, so nothing ran after it: nothing to undo. return; @@ -373,6 +506,12 @@ static void afterThrow(Transaction tx, boolean rollback) { } } + private static void discardNestedCommitHooks(Transaction tx) { + for (int i = tx.synchronizationStart; i < tx.physical.synchronizations.size(); i++) { + ((Registration) tx.physical.synchronizations.get(i)).commitEligible = false; + } + } + /// Undoes a NESTED call's work, back to its savepoint; never throws. private static void rollBackToSavepoint(Transaction tx) { try { @@ -411,11 +550,47 @@ private static void restore(Transaction tx) { } private static void finishNew(Physical p) { + p.completing = true; + checkCommitAllowed(p); + if (p.completed) { + return; + } + boolean commitAttempted = false; + try { + for (int i = 0; i < p.synchronizations.size(); i++) { + Registration r = (Registration) p.synchronizations.get(i); + if (r.commitEligible) { + r.synchronization.beforeCommit(p.readOnly); + } + } + checkCommitAllowed(p); + if (p.completed) { + return; + } + if (p.session != null) { + p.session.commitTransaction(); + } + if (p.db != null) { + commitAttempted = true; + p.db.commitTransaction(); + } + } catch (Error err) { + rollbackPhysical(p, commitAttempted); + throw err; + } catch (TransactionException err) { + rollbackPhysical(p, commitAttempted); + throw err; + } catch (Throwable err) { + rollbackPhysical(p, commitAttempted); + throw new TransactionException("Transaction could not commit: " + err.getMessage(), err); + } + complete(p, Outcome.COMMITTED); + } + + private static void checkCommitAllowed(Physical p) { if (p.rollbackOnly) { rollbackPhysical(p); - throw new TransactionException.UnexpectedRollback("The transaction was rolled " - + "back because a method that joined it failed. Its own method returned " - + "normally, so this is what tells its caller the work was not saved."); + throw new TransactionException.UnexpectedRollback("A joined call marked the transaction rollback-only"); } if (p.localRollback) { rollbackPhysical(p); @@ -423,54 +598,81 @@ private static void finishNew(Physical p) { } if (p.deadline != 0 && System.currentTimeMillis() > p.deadline) { rollbackPhysical(p); - throw new TransactionException.TimedOut("The transaction ran past its timeout " - + "and was rolled back."); - } - if (p.db == null) { - // Never touched the database: nothing began, so nothing commits. - closeSession(p); - return; + throw new TransactionException.TimedOut("The transaction ran past its timeout and was rolled back"); } - try { - if (p.session != null) { - // Flushes the session's pending changes INTO the transaction - // before it commits; the adapter it runs on joined this - // transaction, so the session's own commit sends no COMMIT. - p.session.commitTransaction(); - } - p.db.commitTransaction(); - } catch (Exception err) { - rollbackPhysical(p); - throw new TransactionException("Commit failed; the transaction was rolled " - + "back: " + err.getMessage(), err); - } - closeSession(p); - release(p, false); } private static void rollbackPhysical(Physical p) { - boolean broken = false; + rollbackPhysical(p, false); + } + + private static void rollbackPhysical(Physical p, boolean uncertainCommit) { + if (p.completed) { + return; + } + p.completing = true; + boolean broken = uncertainCommit; if (p.session != null) { try { if (p.session.isTransactionActive()) { p.session.rollbackTransaction(); } - } catch (Exception err) { + } catch (Throwable err) { System.err.println("Could not roll back the transaction's session: " + err); } } - closeSession(p); if (p.db != null) { try { if (p.db.isInTransaction()) { p.db.rollbackTransaction(); } - } catch (Exception err) { + } catch (Throwable err) { broken = true; - System.err.println("Rollback failed; closing the connection so the pool " - + "cannot reuse it: " + err); + System.err.println("Rollback failed; closing the connection: " + err); + } + if (broken) { + p.db.close(); + } + } + complete(p, broken ? Outcome.UNKNOWN : Outcome.ROLLED_BACK); + } + + private static void complete(Physical p, Outcome outcome) { + p.completed = true; + Throwable failure = null; + try { + closeSession(p); + release(p); + if (outcome == Outcome.COMMITTED) { + for (int i = 0; i < p.synchronizations.size(); i++) { + Registration r = (Registration) p.synchronizations.get(i); + if (r.commitEligible) { + try { + r.synchronization.afterCommit(); + } catch (Throwable err) { + if (failure == null) { + failure = err; + } + } + } + } + } + } finally { + try { + for (int i = 0; i < p.synchronizations.size(); i++) { + try { + ((Registration) p.synchronizations.get(i)).synchronization.afterCompletion(outcome); + } catch (Throwable err) { + System.err.println("Transaction completion callback failed: " + err); + } + } + } finally { + p.resources.clear(); + p.synchronizations.clear(); } - release(p, broken); + } + if (failure != null) { + throw new TransactionException("Transaction committed, but an after-commit callback failed", failure); } } @@ -482,21 +684,18 @@ private static void closeSession(Physical p) { p.session = null; try { session.close(); - } catch (Exception err) { + } catch (Throwable err) { System.err.println("Could not close the transaction's session: " + err); } } - private static void release(Physical p, boolean broken) { + private static void release(Physical p) { Database db = p.db; DataSource pool = p.pool; p.db = null; if (db == null || pool == null) { return; } - if (broken) { - db.close(); - } pool.releaseToPool(db); } @@ -525,6 +724,9 @@ static Database joined(DataSource pool) throws IOException { } private static Database materialize(Physical p, DataSource pool) throws IOException { + if (p.completed) { + throw new TransactionException.IllegalState("Transaction has completed; use REQUIRES_NEW for callback database work"); + } if (p.pool == null) { if (pool == null) { return null; @@ -547,14 +749,15 @@ private static Database materialize(Physical p, DataSource pool) throws IOExcept throw new IOException("The transaction ran past its timeout before it used the " + "database"); } - Database db = p.pool.borrowFromPool(); + Database db = null; try { + db = p.pool.borrowFromPool(); if ("sqlite".equals(db.dialect().getName())) { // The ORM relies on it and sets it outside a transaction, which is // the only place SQLite honours it: inside one it is ignored. db.execute("PRAGMA foreign_keys = ON", null); } - db.beginTransaction(p.readOnly); + db.beginTransaction(p.readOnly, p.isolation); if (p.pendingSavepoints != null) { for (int iter = 0 ; iter < p.pendingSavepoints.size() ; iter++) { db.savepoint((String) p.pendingSavepoints.get(iter)); @@ -562,14 +765,17 @@ private static Database materialize(Physical p, DataSource pool) throws IOExcept p.pendingSavepoints = null; } } catch (IOException err) { - try { - if (db.isInTransaction()) { - db.rollbackTransaction(); + p.rollbackOnly = true; + if (db != null) { + try { + if (db.isInTransaction()) { + db.rollbackTransaction(); + } + } catch (IOException ignored) { + db.close(); } - } catch (IOException ignored) { - db.close(); + p.pool.releaseToPool(db); } - p.pool.releaseToPool(db); throw err; } p.db = db; diff --git a/vm/backend/src/com/codename1/backend/annotations/Isolation.java b/vm/backend/src/com/codename1/backend/annotations/Isolation.java new file mode 100644 index 00000000000..5b9ef618e82 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/Isolation.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +/// Isolation requested for one physical transaction. DEFAULT inherits the engine's +/// setting. SQLite supports SERIALIZABLE only; PostgreSQL rejects READ_UNCOMMITTED +/// here rather than silently substituting READ_COMMITTED. MySQL supports all four. +public enum Isolation { + DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Transactional.java b/vm/backend/src/com/codename1/backend/annotations/Transactional.java index 852e7cb5242..9e819cd881d 100644 --- a/vm/backend/src/com/codename1/backend/annotations/Transactional.java +++ b/vm/backend/src/com/codename1/backend/annotations/Transactional.java @@ -57,6 +57,9 @@ public @interface Transactional { /// What to do about a transaction that is already open. Propagation propagation() default Propagation.REQUIRED; + /// Isolation for a new physical transaction. An explicit level joining an + /// existing transaction must match its explicit level, including at savepoints. + Isolation isolation() default Isolation.DEFAULT; /// A hint that the method only reads. Engines that support a read-only /// transaction get one, which lets them skip work and refuse writes. boolean readOnly() default false; diff --git a/vm/backend/src/com/codename1/backend/sql/MySql.java b/vm/backend/src/com/codename1/backend/sql/MySql.java index 6b44a28d41d..ac2163937cc 100644 --- a/vm/backend/src/com/codename1/backend/sql/MySql.java +++ b/vm/backend/src/com/codename1/backend/sql/MySql.java @@ -22,6 +22,8 @@ */ package com.codename1.backend.sql; +import com.codename1.backend.annotations.Isolation; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.LinkedHashMap; @@ -426,6 +428,15 @@ public void beginReadOnly() throws IOException { command("START TRANSACTION READ ONLY"); } + /// Uses a next-transaction setting, never a session default. The enum keeps + /// arbitrary SQL out of the text-protocol transaction control path. + public void begin(boolean readOnly, Isolation isolation) throws IOException { + if (isolation != Isolation.DEFAULT) { + command("SET TRANSACTION ISOLATION LEVEL " + isolation.name().replace('_', ' ')); + } + command(readOnly ? "START TRANSACTION READ ONLY" : "START TRANSACTION READ WRITE"); + } + /// Savepoint control, through the text protocol for the same reason as /// [#begin]. The name is checked to be a plain identifier here as well /// as by the caller, because this is the one text-protocol entry point that diff --git a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java index 626e1c647e8..df26e98c4d4 100644 --- a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java +++ b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java @@ -229,6 +229,10 @@ public abstract Object inBackground(String name, Span parent, Tracer own, Tracin /// [#commit] or [#afterThrow]. public abstract Object begin(int propagation, boolean readOnly, int timeoutSeconds); + /// Begins a transaction with an explicit isolation requirement. + public abstract Object begin(int propagation, boolean readOnly, int timeoutSeconds, + com.codename1.backend.annotations.Isolation isolation); + /// Ends a transaction [#begin] returned, normally. public abstract void commit(Object transaction);