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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/include/storage/sqlite_transaction.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
#include "duckdb/transaction/transaction.hpp"
#include "duckdb/common/case_insensitive_map.hpp"
#include "sqlite_db.hpp"
#include <atomic>
#include <mutex>

namespace duckdb {
class SQLiteCatalog;
Expand All @@ -34,10 +36,23 @@ class SQLiteTransaction : public Transaction {
static SQLiteTransaction &Get(ClientContext &context, Catalog &catalog);

private:
// Transaction state machine
enum class TransactionState {
INIT, // Initial state, transaction not started
STARTED, // Start() called, BEGIN TRANSACTION pending
EXECUTING // BEGIN TRANSACTION executed, transaction is active
};

SQLiteCatalog &sqlite_catalog;
SQLiteDB *db;
SQLiteDB owned_db;
unique_ptr<SQLiteCatalogMap> catalog_map;

// Lazy opening support (thread-safe)
string pending_path; // Path to open when GetDB() is called
std::atomic<bool> db_opened{false}; // Whether the database has been opened
std::atomic<TransactionState> state{TransactionState::INIT}; // Transaction state
std::mutex db_mutex; // Mutex for double-checked locking in GetDB()
};

} // namespace duckdb
70 changes: 65 additions & 5 deletions src/storage/sqlite_transaction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,18 @@ void SQLiteCatalogMap::EraseEntry(const string &entry_name) {
}

SQLiteTransaction::SQLiteTransaction(SQLiteCatalog &sqlite_catalog, TransactionManager &manager, ClientContext &context)
: Transaction(manager, context), sqlite_catalog(sqlite_catalog) {
: Transaction(manager, context), sqlite_catalog(sqlite_catalog), db(nullptr) {
if (sqlite_catalog.InMemory()) {
// in-memory database - get a reference to the in-memory connection
db = sqlite_catalog.GetInMemoryDatabase();
db_opened = true;
} else {
// on-disk database - open a new database connection
owned_db = SQLiteDB::Open(sqlite_catalog.path, sqlite_catalog.options, true);
db = &owned_db;
// on-disk database - defer opening until first actual use
pending_path = sqlite_catalog.path;
db = nullptr;
db_opened = false;
}
state = TransactionState::INIT;
catalog_map = make_uniq<SQLiteCatalogMap>();
}

Expand All @@ -69,16 +72,69 @@ SQLiteTransaction::~SQLiteTransaction() {
}

void SQLiteTransaction::Start() {
db->Execute("BEGIN TRANSACTION");
// Transition from INIT to STARTED
// BEGIN TRANSACTION will be executed when GetDB() is called
D_ASSERT(state.load() == TransactionState::INIT);
state = TransactionState::STARTED;
}

void SQLiteTransaction::Commit() {
// Only commit if transaction is executing (i.e., GetDB() was called)
if (state.load() != TransactionState::EXECUTING) {
return;
}
D_ASSERT(db_opened.load());
db->Execute("COMMIT");
}

void SQLiteTransaction::Rollback() {
// Only rollback if transaction is executing (i.e., GetDB() was called)
if (state.load() != TransactionState::EXECUTING) {
return;
}
D_ASSERT(db_opened.load());
db->Execute("ROLLBACK");
}

SQLiteDB &SQLiteTransaction::GetDB() {
// Fast path: database already opened (in-memory or previously opened on-disk)
if (db_opened.load()) {
// Check if we need to execute BEGIN TRANSACTION
if (state.load() == TransactionState::STARTED) {
lock_guard<mutex> guard(db_mutex);
// Double check after acquiring lock
if (state.load() == TransactionState::STARTED) {
db->Execute("BEGIN TRANSACTION");
state = TransactionState::EXECUTING;
}
}
return *db;
}

// Slow path: need to open database (on-disk only, with mutex protection)
lock_guard<mutex> guard(db_mutex);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added mutex in GetDB() to prevent race condition when multiple threads concurrently open the database file - fast path remains lock-free via atomic check. Any alternative suggestions? Or
should we consider not fixing this issue?


// Double check after acquiring lock
if (db_opened.load()) {
// Another thread opened it, but may still need BEGIN
if (state.load() == TransactionState::STARTED) {
db->Execute("BEGIN TRANSACTION");
state = TransactionState::EXECUTING;
}
return *db;
}

// Open the database file
owned_db = SQLiteDB::Open(pending_path, sqlite_catalog.options, true);
db = &owned_db;
db_opened = true;

// If transaction was started (STARTED state), execute BEGIN TRANSACTION now
if (state.load() == TransactionState::STARTED) {
db->Execute("BEGIN TRANSACTION");
state = TransactionState::EXECUTING;
}

return *db;
}

Expand Down Expand Up @@ -147,6 +203,8 @@ optional_ptr<CatalogEntry> SQLiteTransaction::GetCatalogEntry(const string &entr
if (entry) {
return entry;
}
// Ensure database is opened before accessing
GetDB();
// catalog entry not found - look up table in main SQLite database
auto type = db->GetEntryType(entry_name);
if (type == CatalogType::INVALID) {
Expand Down Expand Up @@ -235,6 +293,8 @@ string GetDropSQL(CatalogType type, const string &table_name, bool cascade) {

void SQLiteTransaction::DropEntry(CatalogType type, const string &table_name, bool cascade) {
catalog_map->EraseEntry(table_name);
// Ensure database is opened before accessing
GetDB();
db->Execute(GetDropSQL(type, table_name, cascade));
}

Expand Down
Loading