diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/H2Database.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/H2Database.kt index 45e3b718530438473780abb3ccb9ce25e0aa6c2e..d6d2f5e9288d785506eea1998acc1bde578d75d1 100644 --- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/H2Database.kt +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/H2Database.kt @@ -32,6 +32,7 @@ import java.sql.ResultSet import java.sql.SQLException import java.sql.Statement import java.util.Properties +import kotlin.concurrent.withLock open class H2Database( private val config: DatabaseConfig, @@ -85,8 +86,7 @@ open class H2Database( } override fun close() { - connectionsLock.lock() - try { + connectionsLock.withLock { // This extra check is mainly added for tests where we might have closed the database // already by resetting the database after each test and then the lifecycle manager // tries to close again. However closing an already closed database doesn't make @@ -106,8 +106,6 @@ open class H2Database( tryToClose(c, LOG) throw DbException(e) } - } finally { - connectionsLock.unlock() } } diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt index bb01002a46ef1623a52cff876a4040ff233e6bd8..df4aebcc82750f7aa016169fa702a15a6e61810d 100644 --- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt @@ -43,6 +43,7 @@ import java.util.concurrent.locks.Lock import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantReadWriteLock import javax.annotation.concurrent.GuardedBy +import kotlin.concurrent.withLock abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val clock: Clock) : Database { @@ -70,6 +71,20 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc """.trimIndent() } + /** + * This lock is used for making sure that either one writable or alternatively an arbitrary + * number of read-only transactions is in progress at the same time. It acts as a barrier + * that blocks threads that try to start a transaction that cannot currently be started + * because of that invariant. + */ + private val transactionLock = ReentrantReadWriteLock(true) + + /** + * In the case of readers, multiple threads can be accessing the database simultaneously. + * To synchronise access to shared fields, we use this lock. + * + * Do not start any transactions while holding [connectionsLock] as this is deadlock-prone. + */ protected val connectionsLock: Lock = ReentrantLock() private val connectionsChanged = connectionsLock.newCondition() @@ -80,15 +95,14 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc private var openConnections = 0 @GuardedBy("connectionsLock") - protected var closed = false + protected var closed = true @Volatile private var wasDirtyOnInitialisation = false - private val lock = ReentrantReadWriteLock(true) - - /* - * Returns true if the database already existed + /** + * Returns true if the database already existed. It is not safe to call this method + * concurrently with [dropAllTablesAndClose] */ internal fun open(driverClass: String, listener: MigrationListener?): Boolean { try { @@ -97,6 +111,11 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc throw DbException(e) } // Open the database and create the tables and indexes if necessary + // First: mark the database as not closed + connectionsLock.withLock { + closed = false + } + // Check if we have the settings table to determine if we're reopening an existing database LOG.info { "checking for settings table" } val reopen = databaseHasSettingsTable() LOG.info { @@ -126,11 +145,8 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc compactAndClose() logDuration(LOG, start) { "Compacting database" } // Allow the next transaction to reopen the DB - connectionsLock.lock() - try { + connectionsLock.withLock { closed = false - } finally { - connectionsLock.unlock() } write { txn -> storeLastCompacted(txn.unbox()) @@ -205,46 +221,41 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc */ private fun startTransaction(readOnly: Boolean): Transaction { // Don't allow reentrant locking - check(lock.readHoldCount <= 0) - check(lock.writeHoldCount <= 0) + check(transactionLock.readHoldCount <= 0) + check(transactionLock.writeHoldCount <= 0) val start = now() if (readOnly) { - lock.readLock().lock() + transactionLock.readLock().lock() logDuration(LOG, start) { "Waiting for read lock" } } else { - lock.writeLock().lock() + transactionLock.writeLock().lock() logDuration(LOG, start) { "Waiting for write lock" } } return try { Transaction(startTransaction(), readOnly) } catch (e: DbException) { - if (readOnly) lock.readLock().unlock() else lock.writeLock().unlock() + if (readOnly) transactionLock.readLock().unlock() else transactionLock.writeLock() + .unlock() throw e } catch (e: RuntimeException) { - if (readOnly) lock.readLock().unlock() else lock.writeLock().unlock() + if (readOnly) transactionLock.readLock().unlock() else transactionLock.writeLock() + .unlock() throw e } } private fun startTransaction(): Connection { - var connection: Connection? - connectionsLock.lock() - connection = try { + var connection = connectionsLock.withLock { if (closed) throw DbClosedException() connections.poll() - } finally { - connectionsLock.unlock() } try { if (connection == null) { // Open a new connection connection = createConnection() connection.autoCommit = false - connectionsLock.lock() - try { + connectionsLock.withLock { openConnections++ - } finally { - connectionsLock.unlock() } } } catch (e: SQLException) { @@ -256,24 +267,18 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc private fun abortTransaction(connection: Connection) { try { connection.rollback() - connectionsLock.lock() - try { + connectionsLock.withLock { connections.add(connection) connectionsChanged.signalAll() - } finally { - connectionsLock.unlock() } } catch (e: SQLException) { // Try to close the connection logException(LOG, e) { "Error while aborting transaction" } tryToClose(connection, LOG) // Whatever happens, allow the database to close - connectionsLock.lock() - try { + connectionsLock.withLock { openConnections-- connectionsChanged.signalAll() - } finally { - connectionsLock.unlock() } } } @@ -284,20 +289,16 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc } catch (e: SQLException) { throw DbException(e) } - connectionsLock.lock() - try { + connectionsLock.withLock { connections.add(connection) connectionsChanged.signalAll() - } finally { - connectionsLock.unlock() } } @Throws(SQLException::class) fun closeAllConnections() { var interrupted = false - connectionsLock.lock() - try { + connectionsLock.withLock { closed = true for (c in connections) c.close() openConnections -= connections.size @@ -313,8 +314,6 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc openConnections -= connections.size connections.clear() } - } finally { - connectionsLock.unlock() } if (interrupted) Thread.currentThread().interrupt() } @@ -388,16 +387,20 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc @Throws(DbException::class) override fun dropAllTablesAndClose() { - connectionsLock.lock() - try { - write { txn -> - val connection: Connection = txn.unbox() - execute(connection, "DROP TABLE settings") - execute(connection, "DROP TABLE contacts") + connectionsLock.withLock { + if (closed) throw DbClosedException() + var c: Connection? = null + try { + c = createConnection() + // Prevent new transactions from starting, wait for any ongoing transactions to finish + closeAllConnections() + execute(c, "DROP TABLE settings") + execute(c, "DROP TABLE contacts") + c.close() + } catch (e: SQLException) { + tryToClose(c, LOG) + throw DbException(e) } - closeAllConnections() - } finally { - connectionsLock.unlock() } } @@ -643,7 +646,8 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc abortTransaction(connection) } } finally { - if (txn.isReadOnly) lock.readLock().unlock() else lock.writeLock().unlock() + if (txn.isReadOnly) transactionLock.readLock().unlock() else transactionLock.writeLock() + .unlock() } } diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeTest1.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeTest1.kt new file mode 100644 index 0000000000000000000000000000000000000000..e140ae19892692057770cc517bc2df8d397e7f37 --- /dev/null +++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeTest1.kt @@ -0,0 +1,31 @@ +package org.briarproject.mailbox.core.setup + +import org.briarproject.mailbox.core.server.IntegrationTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import kotlin.concurrent.thread + +class WipeTest1 : IntegrationTest() { + + @Test + fun test() { + val t1 = thread(name = "dropper") { + db.dropAllTablesAndClose() + } + // This most likely throws a DbException within the thread, but if it does is doesn't make + // the test fail. + val t2 = thread(name = "other") { + addOwnerToken() + } + t1.join() + t2.join() + } + + @AfterEach + override fun clearDb() { + // This is not expected to work because calling dropAllTablesAndClose() on a closed Database + // throws a DbClosedException. + // super.clearDb() + } + +} diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeTest2.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeTest2.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7e0ae452d9eb0f837a310c702497de2e49daba3 --- /dev/null +++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/setup/WipeTest2.kt @@ -0,0 +1,62 @@ +package org.briarproject.mailbox.core.setup + +import org.briarproject.mailbox.core.server.IntegrationTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import kotlin.concurrent.thread +import kotlin.test.assertEquals + +class WipeTest2 : IntegrationTest() { + + var failed = 0 + var succeeded = 0 + + @Synchronized + fun incrementFailure() { + failed += 1 + } + + @Synchronized + fun incrementSuccess() { + succeeded += 1 + } + + @Test + fun test() { + // One of the two dropper threads will succeed, the other will fail. + val t1 = thread(name = "dropper 1") { + try { + db.dropAllTablesAndClose() + incrementSuccess() + } catch (t: Throwable) { + incrementFailure() + } + } + val t2 = thread(name = "dropper 2") { + try { + db.dropAllTablesAndClose() + incrementSuccess() + } catch (t: Throwable) { + incrementFailure() + } + } + // This most likely throws a DbException within the thread, but if it does is doesn't make + // the test fail. + val t3 = thread(name = "other") { + addOwnerToken() + } + t1.join() + t2.join() + t3.join() + assertEquals(1, succeeded) + assertEquals(1, failed) + } + + @AfterEach + override fun clearDb() { + // This is not expected to work because calling dropAllTablesAndClose() on a closed Database + // throws a DbClosedException. + // super.clearDb() + } + +}