Skip to content

Make Database thread-safe via serialized connection access (#9)#28

Merged
jkalias merged 6 commits into
mainfrom
fix-thread-safety-9
Jun 16, 2026
Merged

Make Database thread-safe via serialized connection access (#9)#28
jkalias merged 6 commits into
mainfrom
fix-thread-safety-9

Conversation

@jkalias

@jkalias jkalias commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #9. The library funnels all access through a single shared sqlite3* connection behind a process-wide singleton with no synchronization, so concurrent use is undefined behavior. This serializes access to make concurrent use correct (approach 1 from the issue discussion).

What changed

  • Serialized connection opensqlite3_open_v2(..., SQLITE_OPEN_FULLMUTEX) instead of sqlite3_open.
  • Per-instance db_mutex_ guards every operation that touches db_ (Fetch, Save, SaveAutoIncrement, Update, Delete, UnsafeSql, GetMaxId), so the per-operation BEGIN/COMMIT can no longer interleave on the shared connection (the "cannot start a transaction within a transaction" race). In the auto-increment path the lock spans both the insert and the sqlite3_last_insert_rowid() read, so the returned id always corresponds to this insert (this also closes the race deferred in Fix SaveAutoIncrement to write back a database-assigned, non-reused id (#6) #8).
  • Static instance_mutex_ guards the singleton lifecycle (Initialize/Finalize/Instance), and Instance() now throws a clear error instead of dereferencing null when the database is not initialized.
  • Removed sqlite3_close(db_) from the FetchRecordsQuery / FetchMaxIdQuery error paths, which previously closed the shared connection on a prepare failure (dangling handle + potential double-close on Finalize).

Tests (proving the fix)

Adds tests/concurrency_test.cc — two tests that hammer the shared singleton from 8 threads:

  • ConcurrentAutoIncrementInsertsAllSucceedWithUniqueIds — asserts no operation fails, all 800 rows persist, and all ids are unique.
  • ConcurrentReadsAndWritesDoNotThrow — mixed readers/writers, asserts no crash/throw and the correct final count.

These were verified to fail without the fix: with the connection lock removed, the first test produced only 143 of 800 unique ids (lost/colliding inserts from the nested-transaction race). With the fix, the full suite is 56/56 green.

Scope / follow-up

This serializes writes (and reads), which is correct but not concurrent for writers. True write parallelism (connection-per-thread / pool, WAL) is intentionally out of scope here and tracked as follow-up on #9.

https://claude.ai/code/session_01YLXHcZjrnrdhRZAgqDh6kd


Generated by Claude Code

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.
MSVC in C++11 mode rejected the lambdas (C3493) because the constexpr
loop-bound locals were used without being captured; C++17+ does not
require capturing them, so only the C++11 jobs failed. Capture by
reference so the tests build under every standard in the matrix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 306f486291

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/database.cc Outdated
Comment on lines 50 to 52
std::lock_guard<std::mutex> lock(instance_mutex_);
if (instance_ != nullptr) {
sqlite3_close(instance_->db_);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize Finalize with in-flight operations

If one thread calls Finalize() while another is still using a Database reference returned by Instance(), this only takes instance_mutex_; the CRUD methods take db_mutex_ instead. That allows sqlite3_close(instance_->db_) and delete instance_ to run while an operation is inside ExecutionQuery::Execute(), leaving the worker with a freed sqlite3*/destroyed mutex. This is easy to hit when shutdown races a worker that has not joined yet, so Finalize() needs to coordinate with the same operation lock or otherwise wait for active users before closing/deleting the singleton.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed in 7c9c547. You're right that Finalize() only held instance_mutex_ while operations hold db_mutex_, so it could close/delete the singleton out from under an in-flight operation.

Rather than try to coordinate the two locks (the db_mutex_ lives inside the object being deleted, so "lock then delete" is itself UB, and a held reference couldn't be protected anyway), I moved the singleton to shared ownership:

  • instance_ is now a std::shared_ptr<Database>, and Instance() returns std::shared_ptr<const Database>, so a caller holds a strong reference for the duration of its work.
  • Finalize() just drops the singleton's own reference; the connection is closed by a new ~Database (RAII) only once the last outstanding handle is released. An operation in progress on another thread keeps the database alive until it finishes.

Added a regression test, ConnectionOutlivesFinalizeWhileAHandleIsHeld, which holds a handle, calls Finalize(), and then keeps using the handle — this would have been a use-after-free under the old raw-pointer lifecycle. Call sites (tests, README) now use the handle via ->, and the README documents the threading contract. Full suite is 57/57 green locally.


Generated by Claude Code

claude added 2 commits June 15, 2026 18:15
…ht DB (#9)

Previously Finalize() closed the connection and deleted the singleton while
holding only instance_mutex_, so it could tear the Database down while another
thread was mid-operation (operations hold db_mutex_, not instance_mutex_),
leaving a freed sqlite3*/destroyed mutex — a use-after-free flagged in review.

Move the singleton to shared ownership:

- instance_ is now a std::shared_ptr<Database>, and Instance() returns
  std::shared_ptr<const Database>. Callers hold a strong reference for the
  duration of their work.
- Finalize() just drops the singleton's own reference; the connection is closed
  by a new ~Database (RAII) once the last outstanding handle is released, so an
  in-flight operation keeps the database alive until it completes.
- Update all call sites (tests, README) to use the handle via ->, and document
  the threading contract and handle semantics in the README.

Adds a regression test (ConnectionOutlivesFinalizeWhileAHandleIsHeld) proving a
held handle stays usable after a concurrent Finalize().
Make the shared-ownership contract concrete: explain that Instance()
returns a shared_ptr handle that keeps the database alive, that Finalize()
only drops the singleton's own handle (the connection closes when the last
handle is released), and add a worker-thread example showing a handle
captured by value. Also correct the Finalize() comment in the open/close
section, which implied the connection is closed immediately.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ffbdf8fe3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/database.cc
// Drop the singleton's own reference. The connection is closed by ~Database once the
// last outstanding Instance() handle is released, so an in-flight operation on another
// thread keeps the database alive until it finishes.
instance_.reset();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block reinitialization while retired handles exist

With the new shared-handle lifecycle, this reset makes instance_ null even when older Instance() handles still keep the previous connection alive. A caller can then call Initialize() again before those handles are released, producing two live Database objects: with Initialize("") the new singleton is an empty in-memory DB while the old handle still has its rows, and with a file path the two connections bypass the single db_mutex_ and can write concurrently. Keep lifecycle state, such as a retired weak_ptr, or reject/retry initialization until outstanding handles have expired.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid — and it's a hole the shared-handle lifecycle introduced. Fixed in 119b0b8.

Finalize() now records the retiring instance in a static std::weak_ptr<Database> retired_, and Initialize() throws if that weak_ptr is still alive — i.e. while any outstanding Instance() handle keeps the previous database/connection alive. That prevents two live Database objects (the empty-in-memory-vs-old-rows surprise, and two file connections racing outside the shared db_mutex_). Once the last handle is released the previous instance is destroyed, the weak_ptr expires, and re-initialization succeeds again. Normal Initialize/Finalize cycles with no outstanding handles (e.g. the test fixtures) are unaffected.

Added a regression test ReinitializeWhileAHandleIsHeldIsRejected that holds a handle, finalizes, asserts Initialize() throws, then releases the handle and confirms re-initialization works. Full suite 58/58 green locally.


Generated by Claude Code

…ive (#9)

With the shared-handle lifecycle, Finalize() nulls instance_ but an outstanding
Instance() handle can keep the previous Database (and its connection) alive. A
subsequent Initialize() would then create a second live Database whose
operations use a different db_mutex_ — two connections to the same file racing,
or a fresh empty in-memory DB shadowing the old one.

Track the retiring instance with a weak_ptr and make Initialize() throw if it is
still alive, so reinitialization is only allowed once all handles to the previous
database have been released. Normal init/finalize cycles (no outstanding handles,
e.g. the test fixtures) are unaffected.

Adds a regression test covering reject-then-allow once the handle is released.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 119b0b845d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/database.cc
// last outstanding Instance() handle is released, so an in-flight operation on another
// thread keeps the database alive until it finishes. Track the retiring instance so a
// subsequent Initialize() is rejected until those handles are gone.
retired_ = instance_;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve retired handle on repeated Finalize

When Finalize() is called a second time while a caller still holds an Instance() handle from the first finalized database, instance_ is already null, so this assignment overwrites the non-expired retired_ weak pointer with an empty one. A subsequent Initialize() will then pass the retired_.expired() check and create a second live database/connection while the old handle can still perform operations, reintroducing the split-lock/two-database state this guard is meant to prevent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct, that was a real bug in the guard — fixed in b7a3fa9. Finalize() now returns early when instance_ is already null instead of doing retired_ = instance_, so a repeated Finalize() no longer clobbers a still-valid retired_ with an empty weak_ptr. The guard set by the first Finalize() stays in effect until the outstanding handles are released.

Added a regression test RepeatedFinalizeDoesNotClearTheReinitializationGuard (hold a handle, Finalize() twice, assert Initialize() still throws, then release and confirm re-init works). Full suite 59/59 green locally.


Generated by Claude Code

Finalize() unconditionally did `retired_ = instance_; instance_.reset();`.
On a second Finalize() (instance_ already null) this overwrote a still-valid
retired_ weak_ptr with an empty one, so a later Initialize() would pass the
expired() check and create a second live database while outstanding handles
from the first were still usable — reopening the split-lock/two-database hole
the guard prevents.

Make Finalize() a no-op when there is no instance to retire, preserving the
existing retired_ tracking. Adds a regression test for repeated Finalize while
a handle is held.
@jkalias

jkalias commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: b7a3fa996b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jkalias jkalias merged commit 3dc4de4 into main Jun 16, 2026
12 checks passed
@jkalias jkalias deleted the fix-thread-safety-9 branch June 16, 2026 08:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Library is not thread-safe: shared connection, racy singleton, and non-atomic auto-increment

2 participants