Concurrency and Transactions
Concurrency and Transactions
Applies to: HeliosDB Nano 4.31.1
What is safe to do from more than one caller depends entirely on how those callers reach the engine. Embedded (in-process) access and wire-protocol (server) access have different transaction models, and the difference is not a tuning knob — it is structural.
Read Deployment Modes first if the words “embedded” and “server” are not yet concrete.
Summary
| Embedded library API / REPL | PostgreSQL or MySQL wire connection | |
|---|---|---|
| Concurrent reads | Yes | Yes |
| Single-statement autocommit writes | Yes | Yes |
Explicit BEGIN … COMMIT | Yes — but one at a time per process | Yes — independent per connection |
| Independent overlapping transactions from different callers | No | Yes |
| Crash recovery of committed data | Yes | Yes |
Embedded mode
What works
- Concurrent reads. The storage engine is MVCC-based; readers take a snapshot and do not block each other or writers.
- Autocommit single-statement writes. Each statement executed outside an explicit transaction commits on its own. Multiple threads issuing single-statement writes through the same handle is a supported pattern.
What does not work: independent explicit transactions
An EmbeddedDatabase handle holds one process-global transaction slot
(current_transaction, guarded by a mutex, plus a global_txn_active flag).
Cloning the handle does not give you a second slot — the clones share it.
The consequence: if two callers in the same process each run
BEGIN … work … COMMIT against that handle, they are not in two isolated
transactions. They are interleaving statements into one transaction, and
whichever one commits or rolls back first ends it for both. A rollback in one
caller discards the other caller’s uncommitted work.
This is not a locking bug to be worked around with a retry loop or a longer timeout. There is one transaction per process on the plain embedded API, by construction.
Supported alternatives
-
Use separate PostgreSQL connections for independent sessions. Start the engine as a server (foreground or daemon) and give each caller its own connection — over TCP or a Unix-domain socket (
--pg-socket-dir). This is the supported way to run independent transactions today, and it is what the wire protocol already does internally. -
If the application must share one embedded handle, coordinate around the entire transaction lifetime. The mutual exclusion has to span from
BEGINthrough the finalCOMMITorROLLBACK, including every error path that exits the block early. Serialising only theBEGIN, or only theCOMMIT, does not help — the window that matters is the whole transaction. If a caller can hold that exclusion for a long time, that is an argument for moving to server access rather than for holding it anyway. -
Use single-statement autocommit where it is sufficient. A statement that is already atomic on its own (a single
INSERT,UPDATE … WHERE,INSERT … ON CONFLICT DO UPDATE) needs no explicit transaction and is safe from concurrent callers.
Internal session primitives
The engine does contain lower-level per-session primitives — create_session,
begin_transaction_for_session, execute_for_session,
commit_transaction_for_session, rollback_transaction_for_session,
destroy_session — and each session gets its own transaction with a fresh
snapshot. These exist because the wire-protocol server needs them; they are
internal plumbing, not a supported public embedded API. They are not
documented as a feature, their shape is not covered by any compatibility
promise, and they are not the recommended way to get independent transactions
in embedded mode. Use a server connection instead.
A first-class public embedded session API is a roadmap item, not a current capability.
Server mode (PostgreSQL / MySQL wire)
Each client connection gets its own session with its own transaction state. Two connections can hold two overlapping explicit transactions, each with its own snapshot, exactly as clients expect from a PostgreSQL server.
# Terminal 1psql -h 127.0.0.1 -p 5432BEGIN;UPDATE accounts SET balance = balance - 50 WHERE id = 1;
# Terminal 2 — an independent transaction, not blocked by the abovepsql -h 127.0.0.1 -p 5432BEGIN;UPDATE accounts SET balance = balance + 50 WHERE id = 2;COMMIT;This applies to every PostgreSQL-wire client — psql, psycopg2, asyncpg,
JDBC, pgx, sqlx — and to the MySQL-wire listener (--mysql).
--max-connections (default 100) caps how many client connections the server
will accept at once.
Isolation levels
READ COMMITTED— each statement sees a fresh snapshot of committed data.REPEATABLE READ— one consistent snapshot taken atBEGIN. Concurrent writers to the same row are resolved first-committer-wins; the loser aborts with40001 serialization_failure. Write skew is permitted.SNAPSHOTis an alias for this level.READ UNCOMMITTED— mapped toREAD COMMITTED; there are no dirty reads.
SERIALIZABLE is served as snapshot isolation and does not prevent write
skew. The engine does not silently pretend otherwise: the behaviour is governed
by storage.serializable_policy.
"warn"(default) — the transaction runs with snapshot isolation and aWARNINGnotice is sent to the client."error"— a request forSERIALIZABLEis rejected outright, leaving the session untouched.
If your correctness argument depends on true serializability, set the policy to
"error" so the assumption fails loudly instead of quietly.
Durability and replication
Two different write-ahead logs are involved, and they answer different questions.
Local crash-recovery WAL. Always on. A committed transaction is recoverable after a crash, in every access mode, including writes made inside explicit transactions. This has never depended on replication being configured.
Logical WAL (replication / CDC). This is what feeds standbys and change-data
consumers. Before engine v4.8.0, writes made inside an explicit or session
transaction produced no logical-WAL records at all — local durability was
fine, but those writes were never shipped downstream, so a primary and its
standby diverged silently for anything an ORM wrapped in BEGIN … COMMIT. That
is fixed: since v4.8.0 transactional writes reach the logical WAL, and
synchronous/semi-synchronous commits actually wait for standby acknowledgement
instead of reporting success for something they never sent.
Remaining limitation. A standby applies replicated operations one at a time, so it can transiently observe a partially-applied transaction before converging. The WAL format already carries transaction markers for atomic apply, but nothing emits them yet. Do not build a standby-side reader that assumes it will only ever see whole transactions.
If you have been replicating from a primary older than v4.8.0, the standby is missing every transactional write since it was seeded; upgrading fixes replication going forward but does not backfill. Re-seed any standby whose contents you need to trust.
Frequently asked
Can embedded threads run independent transactions? No, not on the plain embedded API. One process-global transaction slot per handle, shared across clones.
Is “database is locked” a tuning problem? No. It means a second process tried to open a data directory that another process already owns. One process per data directory; use server access if several callers need the same data.
Does a longer timeout fix concurrent embedded transactions?
No. Timeouts govern how long a caller waits, not how many transaction slots
exist.
Is multi-user access supported at all? Yes — over the wire. Each connection is an independent session with independent transaction state.
Do embedded writes replicate? Yes, including transactional writes, since v4.8.0 — subject to the partially-applied-transaction caveat above.
Related
- Deployment Modes — storage mode vs. access mode
- Session Management API — internal session primitives (architecture note, not a supported public API)
- Multi-User Transactions Architecture
- Migration Guide — the embedded → server transition