Skip to content

Commit 306f486

Browse files
committed
Make Database thread-safe via serialized connection access (#9)
The library funnels all access through a single shared sqlite3* connection behind a process-wide singleton, with no synchronization. Concurrent use raced: each operation's BEGIN/COMMIT interleaved on the shared connection ("cannot start a transaction within a transaction"), the singleton lifecycle was unguarded, and query error paths closed the shared connection out from under other callers. This serializes access (approach chosen for #9): - Open the connection in serialized mode (sqlite3_open_v2 + SQLITE_OPEN_FULLMUTEX). - Guard every operation that touches db_ with a per-instance mutex, so the per-operation transaction is never interleaved. The lock in the auto-increment path spans the insert and the last_insert_rowid read, so the returned id always corresponds to this insert. - Guard the singleton lifecycle (Initialize/Finalize/Instance) with a static mutex, and make Instance() throw a clear error instead of dereferencing null when the database is not initialized. - Stop closing the shared connection from FetchRecordsQuery/FetchMaxIdQuery error paths (this left a dangling handle and could double-close on Finalize). Adds concurrency tests that hammer the shared instance from multiple threads and assert all operations succeed, every row persists, and ids stay unique.
1 parent 39a2454 commit 306f486

5 files changed

Lines changed: 184 additions & 7 deletions

File tree

include/database.h

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#pragma once
2424

2525
#include <stdexcept>
26+
#include <mutex>
2627
#include <string>
2728
#include <typeinfo>
2829
#include <vector>
@@ -98,9 +99,7 @@ class REFLECTION_EXPORT Database {
9899
int64_t GetMaxId() const {
99100
const auto type_id = typeid(T).name();
100101
const auto& record = GetRecord(type_id);
101-
FetchMaxIdQuery query(db_, record);
102-
const auto max_id = query.GetMaxId();
103-
return max_id;
102+
return GetMaxId(record);
104103
}
105104

106105
/// Saves a given record in the database.
@@ -208,6 +207,9 @@ class REFLECTION_EXPORT Database {
208207
/// Returns a record type from its type information, retrieved from typeid(...).name()
209208
static const Reflection& GetRecord(const std::string& type_id);
210209

210+
/// Returns the max id currently stored for a given record (SELECT MAX(id) FROM table)
211+
int64_t GetMaxId(const Reflection& record) const;
212+
211213
/// Creates concrete record types with initialized members,
212214
/// based on the textual representation of results from a fetch query
213215
template <typename T>
@@ -235,6 +237,14 @@ class REFLECTION_EXPORT Database {
235237
void Delete(const Reflection& record, const QueryPredicateBase* predicate) const;
236238

237239
static Database* instance_;
240+
241+
/// Guards the singleton lifecycle (Initialize / Finalize / Instance)
242+
static std::mutex instance_mutex_;
243+
238244
sqlite3* db_;
245+
246+
/// Serializes all access to the shared connection db_, so that the per-operation
247+
/// BEGIN/COMMIT transaction is never interleaved across threads
248+
mutable std::mutex db_mutex_;
239249
};
240250
} // namespace sqlite_reflection

src/database.cc

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,22 @@
2222

2323
#include "database.h"
2424

25+
#include <mutex>
2526
#include <stdexcept>
2627

2728
#include "internal/sqlite3.h"
2829
#include "queries.h"
2930

3031
namespace sqlite_reflection {
3132
Database* Database::instance_ = nullptr;
33+
std::mutex Database::instance_mutex_;
3234

3335
const ReflectionRegister& GetReflectionRegister() {
3436
return *GetReflectionRegisterInstance();
3537
}
3638

3739
void Database::Initialize(const std::string& path) {
40+
std::lock_guard<std::mutex> lock(instance_mutex_);
3841
if (instance_ != nullptr) {
3942
throw std::invalid_argument("Database has already been initialized");
4043
}
@@ -44,6 +47,7 @@ void Database::Initialize(const std::string& path) {
4447
}
4548

4649
void Database::Finalize() {
50+
std::lock_guard<std::mutex> lock(instance_mutex_);
4751
if (instance_ != nullptr) {
4852
sqlite3_close(instance_->db_);
4953
delete instance_;
@@ -52,7 +56,10 @@ void Database::Finalize() {
5256
}
5357

5458
Database::Database(const char* path) : db_(nullptr) {
55-
if (sqlite3_open(path, &db_)) {
59+
// Open in serialized threading mode so the shared connection is safe to use from
60+
// multiple threads; access is additionally serialized through db_mutex_.
61+
const int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX;
62+
if (sqlite3_open_v2(path, &db_, flags, nullptr)) {
5663
throw std::invalid_argument("Database could not be initialized");
5764
}
5865

@@ -65,10 +72,15 @@ Database::Database(const char* path) : db_(nullptr) {
6572
}
6673

6774
const Database& Database::Instance() {
75+
std::lock_guard<std::mutex> lock(instance_mutex_);
76+
if (instance_ == nullptr) {
77+
throw std::runtime_error("Database has not been initialized; call Database::Initialize() first");
78+
}
6879
return *instance_;
6980
}
7081

7182
FetchQueryResults Database::Fetch(const Reflection& record, const QueryPredicateBase* predicate) const {
83+
std::lock_guard<std::mutex> lock(db_mutex_);
7284
FetchRecordsQuery query(db_, record, predicate);
7385
return query.GetResults();
7486
}
@@ -77,28 +89,41 @@ const Reflection& Database::GetRecord(const std::string& type_id) {
7789
return GetReflectionRegister().records.at(type_id);
7890
}
7991

92+
int64_t Database::GetMaxId(const Reflection& record) const {
93+
std::lock_guard<std::mutex> lock(db_mutex_);
94+
FetchMaxIdQuery query(db_, record);
95+
return query.GetMaxId();
96+
}
97+
8098
void Database::Save(void* p, const Reflection& record) const {
99+
std::lock_guard<std::mutex> lock(db_mutex_);
81100
InsertQuery query(db_, record, p);
82101
query.Execute();
83102
}
84103

85104
int64_t Database::SaveAutoIncrement(void* p, const Reflection& record) const {
105+
// The lock spans both the insert and the rowid read so that, on the shared connection,
106+
// sqlite3_last_insert_rowid() reflects this insert and not one from another thread.
107+
std::lock_guard<std::mutex> lock(db_mutex_);
86108
InsertQuery query(db_, record, p, true);
87109
query.Execute();
88110
return sqlite3_last_insert_rowid(db_);
89111
}
90112

91113
void Database::Update(void* p, const Reflection& record) const {
114+
std::lock_guard<std::mutex> lock(db_mutex_);
92115
UpdateQuery query(db_, record, p);
93116
query.Execute();
94117
}
95118

96119
void Database::Delete(const Reflection& record, const QueryPredicateBase* predicate) const {
120+
std::lock_guard<std::mutex> lock(db_mutex_);
97121
DeleteQuery query(db_, record, predicate);
98122
query.Execute();
99123
}
100124

101125
void Database::UnsafeSql(const std::string& raw_sql_query) const {
126+
std::lock_guard<std::mutex> lock(db_mutex_);
102127
SqlQuery sql(db_, raw_sql_query);
103128
sql.Execute();
104129
}

src/queries.cc

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,6 @@ int64_t FetchMaxIdQuery::GetMaxId() {
293293
const auto sql = PrepareSql();
294294

295295
if (sqlite3_prepare_v2(db_, sql.data(), -1, &stmt_, nullptr)) {
296-
sqlite3_close(db_);
297296
throw std::runtime_error("Could not retrieve max id for table " + record_.name);
298297
}
299298

@@ -323,7 +322,6 @@ FetchQueryResults FetchRecordsQuery::GetResults() {
323322
const auto sql = PrepareSql();
324323

325324
if (sqlite3_prepare_v2(db_, sql.data(), -1, &stmt_, nullptr)) {
326-
sqlite3_close(db_);
327325
throw std::runtime_error((sql + ": could not get results").data());
328326
}
329327
BindValues(stmt_, predicate_->Bindings());

tests/CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ source_group("src" FILES ${TEST_SOURCES})
2121
# Set Properties->General->Configuration Type to Application
2222
add_executable (${EXENAME} ${TEST_SOURCES} ${TEST_HEADERS})
2323

24+
# The concurrency tests use std::thread, which needs the platform threading library
25+
find_package(Threads REQUIRED)
26+
2427
# Properties->Linker->Input->Additional Dependencies
25-
target_link_libraries(${EXENAME} PUBLIC sqlite_reflection gtest_main)
28+
target_link_libraries(${EXENAME} PUBLIC sqlite_reflection gtest_main Threads::Threads)
2629

2730
# Creates a folder "executables" and adds target project under it
2831
set_property(TARGET ${EXENAME} PROPERTY FOLDER "executables")

tests/concurrency_test.cc

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2026 Ioannis Kaliakatsos
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
#include "database.h"
24+
25+
#include <gtest/gtest.h>
26+
27+
#include <atomic>
28+
#include <cstdint>
29+
#include <set>
30+
#include <thread>
31+
#include <vector>
32+
33+
#include "person.h"
34+
35+
using namespace sqlite_reflection;
36+
37+
// These tests exercise the shared singleton connection from many threads at once.
38+
// Before the connection access was serialized (issue #9), concurrent writers raced
39+
// on a single sqlite3* and each operation's BEGIN/COMMIT interleaved, producing
40+
// "cannot start a transaction within a transaction" failures and intermittent
41+
// crashes. They deterministically pass only once access is serialized.
42+
class ConcurrencyTest : public ::testing::Test {
43+
void SetUp() override {
44+
Database::Initialize("");
45+
}
46+
47+
void TearDown() override {
48+
Database::Finalize();
49+
}
50+
};
51+
52+
TEST_F(ConcurrencyTest, ConcurrentAutoIncrementInsertsAllSucceedWithUniqueIds) {
53+
const auto& db = Database::Instance();
54+
55+
constexpr int kThreads = 8;
56+
constexpr int kInsertsPerThread = 100;
57+
constexpr int kExpected = kThreads * kInsertsPerThread;
58+
59+
std::atomic<int> failures{0};
60+
std::vector<std::thread> workers;
61+
workers.reserve(kThreads);
62+
for (int t = 0; t < kThreads; ++t) {
63+
workers.emplace_back([&db, &failures] {
64+
for (int i = 0; i < kInsertsPerThread; ++i) {
65+
try {
66+
Person p{L"john", L"doe", 30};
67+
db.SaveAutoIncrement(p);
68+
} catch (...) {
69+
failures.fetch_add(1, std::memory_order_relaxed);
70+
}
71+
}
72+
});
73+
}
74+
for (auto& w : workers) {
75+
w.join();
76+
}
77+
78+
// No operation may fail (e.g. with a nested-transaction error).
79+
EXPECT_EQ(0, failures.load());
80+
81+
// Every insert must be persisted exactly once.
82+
const auto all = db.FetchAll<Person>();
83+
EXPECT_EQ(kExpected, static_cast<int>(all.size()));
84+
85+
// Every row must have received a distinct, database-assigned id.
86+
std::set<int64_t> ids;
87+
for (const auto& person : all) {
88+
ids.insert(person.id);
89+
}
90+
EXPECT_EQ(kExpected, static_cast<int>(ids.size()));
91+
}
92+
93+
TEST_F(ConcurrencyTest, ConcurrentReadsAndWritesDoNotThrow) {
94+
const auto& db = Database::Instance();
95+
96+
constexpr int kWriters = 4;
97+
constexpr int kReaders = 4;
98+
constexpr int kInsertsPerWriter = 100;
99+
constexpr int kReadsPerReader = 100;
100+
constexpr int kExpected = kWriters * kInsertsPerWriter;
101+
102+
std::atomic<int> failures{0};
103+
std::vector<std::thread> workers;
104+
workers.reserve(kWriters + kReaders);
105+
106+
for (int t = 0; t < kWriters; ++t) {
107+
workers.emplace_back([&db, &failures] {
108+
for (int i = 0; i < kInsertsPerWriter; ++i) {
109+
try {
110+
Person p{L"jane", L"roe", 41};
111+
db.SaveAutoIncrement(p);
112+
} catch (...) {
113+
failures.fetch_add(1, std::memory_order_relaxed);
114+
}
115+
}
116+
});
117+
}
118+
119+
for (int t = 0; t < kReaders; ++t) {
120+
workers.emplace_back([&db, &failures] {
121+
for (int i = 0; i < kReadsPerReader; ++i) {
122+
try {
123+
// Reading concurrently with writers must not crash or throw.
124+
volatile auto count = db.FetchAll<Person>().size();
125+
(void)count;
126+
} catch (...) {
127+
failures.fetch_add(1, std::memory_order_relaxed);
128+
}
129+
}
130+
});
131+
}
132+
133+
for (auto& w : workers) {
134+
w.join();
135+
}
136+
137+
EXPECT_EQ(0, failures.load());
138+
139+
const auto all = db.FetchAll<Person>();
140+
EXPECT_EQ(kExpected, static_cast<int>(all.size()));
141+
}

0 commit comments

Comments
 (0)