Skip to content

Commit 801ef07

Browse files
committed
Merge bitcoin/bitcoin#29112: sqlite: Disallow writing from multiple SQLiteBatchs
cfcb9b1 test: wallet, coverage for concurrent db transactions (furszy) 548ecd1 tests: Test for concurrent writes with db tx (Ava Chow) 395bcd2 sqlite: Ensure that only one SQLiteBatch is writing to db at a time (Ava Chow) Pull request description: The way that we have configured SQLite to run means that only one database transaction can be open at a time. Typically, each individual read and write operation will be its own transaction that is opened and committed automatically by SQLite. However, sometimes we want these operations to be batched into a multi-statement transaction, so `SQLiteBatch::TxnBegin`, `SQLiteBatch::TxnCommit`, and `SQLiteBatch::TxnAbort` are used to manage the transaction of the database. However, once a db transaction is begun with one `SQLiteBatch`, any operations performed by another `SQLiteBatch` will also occur within the same transaction. Furthermore, those other `SQLiteBatch`s will not be expecting a transaction to be active, and will abort it once the `SQLiteBatch` is destructed. This is problematic as it will prevent some data from being written, and also cause the `SQLiteBatch` that opened the transaction in the first place to be in an unexpected state and throw an error. To avoid this situation, we need to prevent the multiple batches from writing at the same time. To do so, I've implemented added a `CSemaphore` within `SQLiteDatabase` which will be used by any `SQLiteBatch` trying to do a write operation. `wait()` is called by `TxnBegin`, and at the beginning of `WriteKey`, `EraseKey`, and `ErasePrefix`. `post()` is called in `TxnCommit`, `TxnAbort` and at the end of `WriteKey`, `EraseKey`, and `ErasePrefix`. To avoid deadlocking on ` TxnBegin()` followed by a `WriteKey()`, `SQLiteBatch will now also track whether a transaction is in progress so that it knows whether to use the semaphore. This issue is not a problem for BDB wallets since BDB uses WAL and provides transaction objects that must be used if an operation is to occur within a transaction. Specifically, we either pass a transaction pointer, or a nullptr, to all BDB operations, and this allows for concurrent transactions so it doesn't have this problem. Fixes #29110 ACKs for top commit: josibake: ACK bitcoin/bitcoin@cfcb9b1 furszy: ACK cfcb9b1 ryanofsky: Code review ACK cfcb9b1. This looks great and I think it is ready for merge. Just holding off because josibake seemed ready to review bitcoin/bitcoin#29112 (comment) and might have more feedback. Tree-SHA512: 2dd5a8e76df52451a40e0b8a87c7139d68a0d8e1bf2ebc79168cc313e192dab87cfa4270ff17fea4f7b370060d3bc9b5d294d50f7e07994d9b5a69b40397c927
2 parents 60ac503 + cfcb9b1 commit 801ef07

File tree

4 files changed

+129
-6
lines changed

4 files changed

+129
-6
lines changed

src/wallet/sqlite.cpp

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Mutex SQLiteDatabase::g_sqlite_mutex;
110110
int SQLiteDatabase::g_sqlite_count = 0;
111111

112112
SQLiteDatabase::SQLiteDatabase(const fs::path& dir_path, const fs::path& file_path, const DatabaseOptions& options, bool mock)
113-
: WalletDatabase(), m_mock(mock), m_dir_path(fs::PathToString(dir_path)), m_file_path(fs::PathToString(file_path)), m_use_unsafe_sync(options.use_unsafe_sync)
113+
: WalletDatabase(), m_mock(mock), m_dir_path(fs::PathToString(dir_path)), m_file_path(fs::PathToString(file_path)), m_write_semaphore(1), m_use_unsafe_sync(options.use_unsafe_sync)
114114
{
115115
{
116116
LOCK(g_sqlite_mutex);
@@ -408,7 +408,7 @@ void SQLiteBatch::Close()
408408
bool force_conn_refresh = false;
409409

410410
// If we began a transaction, and it wasn't committed, abort the transaction in progress
411-
if (m_database.HasActiveTxn()) {
411+
if (m_txn) {
412412
if (TxnAbort()) {
413413
LogPrintf("SQLiteBatch: Batch closed unexpectedly without the transaction being explicitly committed or aborted\n");
414414
} else {
@@ -442,6 +442,8 @@ void SQLiteBatch::Close()
442442
m_database.Close();
443443
try {
444444
m_database.Open();
445+
// If TxnAbort failed and we refreshed the connection, the semaphore was not released, so release it here to avoid deadlocks on future writes.
446+
m_database.m_write_semaphore.post();
445447
} catch (const std::runtime_error&) {
446448
// If open fails, cleanup this object and rethrow the exception
447449
m_database.Close();
@@ -493,13 +495,19 @@ bool SQLiteBatch::WriteKey(DataStream&& key, DataStream&& value, bool overwrite)
493495
if (!BindBlobToStatement(stmt, 1, key, "key")) return false;
494496
if (!BindBlobToStatement(stmt, 2, value, "value")) return false;
495497

498+
// Acquire semaphore if not previously acquired when creating a transaction.
499+
if (!m_txn) m_database.m_write_semaphore.wait();
500+
496501
// Execute
497502
int res = sqlite3_step(stmt);
498503
sqlite3_clear_bindings(stmt);
499504
sqlite3_reset(stmt);
500505
if (res != SQLITE_DONE) {
501506
LogPrintf("%s: Unable to execute statement: %s\n", __func__, sqlite3_errstr(res));
502507
}
508+
509+
if (!m_txn) m_database.m_write_semaphore.post();
510+
503511
return res == SQLITE_DONE;
504512
}
505513

@@ -511,13 +519,19 @@ bool SQLiteBatch::ExecStatement(sqlite3_stmt* stmt, Span<const std::byte> blob)
511519
// Bind: leftmost parameter in statement is index 1
512520
if (!BindBlobToStatement(stmt, 1, blob, "key")) return false;
513521

522+
// Acquire semaphore if not previously acquired when creating a transaction.
523+
if (!m_txn) m_database.m_write_semaphore.wait();
524+
514525
// Execute
515526
int res = sqlite3_step(stmt);
516527
sqlite3_clear_bindings(stmt);
517528
sqlite3_reset(stmt);
518529
if (res != SQLITE_DONE) {
519530
LogPrintf("%s: Unable to execute statement: %s\n", __func__, sqlite3_errstr(res));
520531
}
532+
533+
if (!m_txn) m_database.m_write_semaphore.post();
534+
521535
return res == SQLITE_DONE;
522536
}
523537

@@ -634,30 +648,43 @@ std::unique_ptr<DatabaseCursor> SQLiteBatch::GetNewPrefixCursor(Span<const std::
634648

635649
bool SQLiteBatch::TxnBegin()
636650
{
637-
if (!m_database.m_db || m_database.HasActiveTxn()) return false;
651+
if (!m_database.m_db || m_txn) return false;
652+
m_database.m_write_semaphore.wait();
653+
Assert(!m_database.HasActiveTxn());
638654
int res = Assert(m_exec_handler)->Exec(m_database, "BEGIN TRANSACTION");
639655
if (res != SQLITE_OK) {
640656
LogPrintf("SQLiteBatch: Failed to begin the transaction\n");
657+
m_database.m_write_semaphore.post();
658+
} else {
659+
m_txn = true;
641660
}
642661
return res == SQLITE_OK;
643662
}
644663

645664
bool SQLiteBatch::TxnCommit()
646665
{
647-
if (!m_database.HasActiveTxn()) return false;
666+
if (!m_database.m_db || !m_txn) return false;
667+
Assert(m_database.HasActiveTxn());
648668
int res = Assert(m_exec_handler)->Exec(m_database, "COMMIT TRANSACTION");
649669
if (res != SQLITE_OK) {
650670
LogPrintf("SQLiteBatch: Failed to commit the transaction\n");
671+
} else {
672+
m_txn = false;
673+
m_database.m_write_semaphore.post();
651674
}
652675
return res == SQLITE_OK;
653676
}
654677

655678
bool SQLiteBatch::TxnAbort()
656679
{
657-
if (!m_database.HasActiveTxn()) return false;
680+
if (!m_database.m_db || !m_txn) return false;
681+
Assert(m_database.HasActiveTxn());
658682
int res = Assert(m_exec_handler)->Exec(m_database, "ROLLBACK TRANSACTION");
659683
if (res != SQLITE_OK) {
660684
LogPrintf("SQLiteBatch: Failed to abort the transaction\n");
685+
} else {
686+
m_txn = false;
687+
m_database.m_write_semaphore.post();
661688
}
662689
return res == SQLITE_OK;
663690
}

src/wallet/sqlite.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ class SQLiteBatch : public DatabaseBatch
5858
sqlite3_stmt* m_delete_stmt{nullptr};
5959
sqlite3_stmt* m_delete_prefix_stmt{nullptr};
6060

61+
/** Whether this batch has started a database transaction and whether it owns SQLiteDatabase::m_write_semaphore.
62+
* If the batch starts a db tx, it acquires the semaphore and sets this to true, keeping the semaphore
63+
* until the transaction ends to prevent other batch objects from writing to the database.
64+
*
65+
* If this batch did not start a transaction, the semaphore is acquired transiently when writing and m_txn
66+
* is not set.
67+
*
68+
* m_txn is different from HasActiveTxn() as it is only true when this batch has started the transaction,
69+
* not just when any batch has started a transaction.
70+
*/
71+
bool m_txn{false};
72+
6173
void SetupSQLStatements();
6274
bool ExecStatement(sqlite3_stmt* stmt, Span<const std::byte> blob);
6375

@@ -115,6 +127,10 @@ class SQLiteDatabase : public WalletDatabase
115127

116128
~SQLiteDatabase();
117129

130+
// Batches must acquire this semaphore on writing, and release when done writing.
131+
// This ensures that only one batch is modifying the database at a time.
132+
CSemaphore m_write_semaphore;
133+
118134
bool Verify(bilingual_str& error);
119135

120136
/** Open the database if it is not already opened */

src/wallet/test/db_tests.cpp

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,48 @@ BOOST_AUTO_TEST_CASE(txn_close_failure_dangling_txn)
279279
BOOST_CHECK(!batch2->Exists(key));
280280
}
281281

282-
#endif // USE_SQLITE
282+
BOOST_AUTO_TEST_CASE(concurrent_txn_dont_interfere)
283+
{
284+
std::string key = "key";
285+
std::string value = "value";
286+
std::string value2 = "value_2";
283287

288+
DatabaseOptions options;
289+
DatabaseStatus status;
290+
bilingual_str error;
291+
const auto& database = MakeSQLiteDatabase(m_path_root / "sqlite", options, status, error);
292+
293+
std::unique_ptr<DatabaseBatch> handler = Assert(database)->MakeBatch();
294+
295+
// Verify concurrent db transactions does not interfere between each other.
296+
// Start db txn, write key and check the key does exist within the db txn.
297+
BOOST_CHECK(handler->TxnBegin());
298+
BOOST_CHECK(handler->Write(key, value));
299+
BOOST_CHECK(handler->Exists(key));
300+
301+
// But, the same key, does not exist in another handler
302+
std::unique_ptr<DatabaseBatch> handler2 = Assert(database)->MakeBatch();
303+
BOOST_CHECK(handler2->Exists(key));
304+
305+
// Attempt to commit the handler txn calling the handler2 methods.
306+
// Which, must not be possible.
307+
BOOST_CHECK(!handler2->TxnCommit());
308+
BOOST_CHECK(!handler2->TxnAbort());
309+
310+
// Only the first handler can commit the changes.
311+
BOOST_CHECK(handler->TxnCommit());
312+
// And, once commit is completed, handler2 can read the record
313+
std::string read_value;
314+
BOOST_CHECK(handler2->Read(key, read_value));
315+
BOOST_CHECK_EQUAL(read_value, value);
316+
317+
// Also, once txn is committed, single write statements are re-enabled.
318+
// Which means that handler2 can read the record changes directly.
319+
BOOST_CHECK(handler->Write(key, value2, /*fOverwrite=*/true));
320+
BOOST_CHECK(handler2->Read(key, read_value));
321+
BOOST_CHECK_EQUAL(read_value, value2);
322+
}
323+
#endif // USE_SQLITE
284324

285325
BOOST_AUTO_TEST_SUITE_END()
286326
} // namespace wallet

test/functional/wallet_descriptor.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
except ImportError:
1010
pass
1111

12+
import concurrent.futures
13+
1214
from test_framework.blocktools import COINBASE_MATURITY
15+
from test_framework.descriptors import descsum_create
1316
from test_framework.test_framework import BitcoinTestFramework
1417
from test_framework.util import (
1518
assert_equal,
@@ -33,6 +36,41 @@ def skip_test_if_missing_module(self):
3336
self.skip_if_no_sqlite()
3437
self.skip_if_no_py_sqlite3()
3538

39+
def test_concurrent_writes(self):
40+
self.log.info("Test sqlite concurrent writes are in the correct order")
41+
self.restart_node(0, extra_args=["-unsafesqlitesync=0"])
42+
self.nodes[0].createwallet(wallet_name="concurrency", blank=True)
43+
wallet = self.nodes[0].get_wallet_rpc("concurrency")
44+
# First import a descriptor that uses hardened dervation so that topping up
45+
# Will require writing a ton to db
46+
wallet.importdescriptors([{"desc":descsum_create("wpkh(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/0h/0h/*h)"), "timestamp": "now", "active": True}])
47+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as thread:
48+
topup = thread.submit(wallet.keypoolrefill, newsize=1000)
49+
50+
# Then while the topup is running, we need to do something that will call
51+
# ChainStateFlushed which will trigger a write to the db, hopefully at the
52+
# same time that the topup still has an open db transaction.
53+
self.nodes[0].cli.gettxoutsetinfo()
54+
assert_equal(topup.result(), None)
55+
56+
wallet.unloadwallet()
57+
58+
# Check that everything was written
59+
wallet_db = self.nodes[0].wallets_path / "concurrency" / self.wallet_data_filename
60+
conn = sqlite3.connect(wallet_db)
61+
with conn:
62+
# Retrieve the bestblock_nomerkle record
63+
bestblock_rec = conn.execute("SELECT value FROM main WHERE hex(key) = '1262657374626C6F636B5F6E6F6D65726B6C65'").fetchone()[0]
64+
# Retrieve the number of descriptor cache records
65+
# Since we store binary data, sqlite's comparison operators don't work everywhere
66+
# so just retrieve all records and process them ourselves.
67+
db_keys = conn.execute("SELECT key FROM main").fetchall()
68+
cache_records = len([k[0] for k in db_keys if b"walletdescriptorcache" in k[0]])
69+
conn.close()
70+
71+
assert_equal(bestblock_rec[5:37][::-1].hex(), self.nodes[0].getbestblockhash())
72+
assert_equal(cache_records, 1000)
73+
3674
def run_test(self):
3775
if self.is_bdb_compiled():
3876
# Make a legacy wallet and check it is BDB
@@ -240,6 +278,8 @@ def run_test(self):
240278
conn.close()
241279
assert_raises_rpc_error(-4, "Unexpected legacy entry in descriptor wallet found.", self.nodes[0].loadwallet, "crashme")
242280

281+
self.test_concurrent_writes()
282+
243283

244284
if __name__ == '__main__':
245285
WalletDescriptorTest().main ()

0 commit comments

Comments
 (0)