Skip to content

Commit cf85619

Browse files
committed
documented transaction isolation behavior
1 parent 81a57f7 commit cf85619

4 files changed

Lines changed: 332 additions & 1 deletion

File tree

docs/API.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,41 @@ await db.rollbackTransaction(); // ROLLBACK TRANSACTION
613613
await db.endTransaction(commit); // COMMIT or ROLLBACK (ignores "no transaction" errors)
614614
```
615615

616+
> **Important: Transaction Isolation Limitations**
617+
>
618+
> SQLite provides isolation between **different database connections**, but **NOT** between operations on the **same connection**.
619+
>
620+
> **Key behaviors:**
621+
> - `beginTransaction()` uses `BEGIN IMMEDIATE TRANSACTION` which acquires a write lock immediately
622+
> - If another connection tries to start a transaction while one is active, it will fail with `SQLITE_BUSY: database is locked`
623+
> - This is expected SQLite behavior and prevents deadlocks
624+
>
625+
> **For concurrent transactions with isolation:**
626+
> ```javascript
627+
> // Option 1: Sequential on same connection (recommended)
628+
> await db.transactionalize(async () => { /* ... */ });
629+
> await db.transactionalize(async () => { /* ... */ });
630+
>
631+
> // Option 2: Separate connections with retry logic
632+
> const db1 = await SqliteDatabase.open('my.db');
633+
> const db2 = await SqliteDatabase.open('my.db');
634+
>
635+
> // Transactions on db1 and db2 are isolated from each other
636+
> // But concurrent write transactions will fail with SQLITE_BUSY
637+
> // You must handle this error in your application:
638+
> try {
639+
> await db2.beginTransaction();
640+
> } catch (err) {
641+
> if (err.message.includes('SQLITE_BUSY') || err.message.includes('database is locked')) {
642+
> // Wait and retry, or queue the operation
643+
> await new Promise(resolve => setTimeout(resolve, 100));
644+
> // retry...
645+
> }
646+
> }
647+
> ```
648+
>
649+
> See [SQLite Isolation Documentation](https://www.sqlite.org/isolation.html) for more details.
650+
616651
#### SqliteDatabase.backup
617652
618653
Creates a backup of the database.

lib/promise/database.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,18 @@ class SqliteDatabase {
324324
/**
325325
* Run callback inside a database transaction
326326
*
327+
* IMPORTANT: SQLite provides isolation between different database connections,
328+
* but NOT between operations on the same connection. If you need concurrent
329+
* transactions with proper isolation, use separate SqliteDatabase instances.
330+
*
331+
* Note: This uses BEGIN IMMEDIATE TRANSACTION which acquires a write lock
332+
* immediately. If another connection holds a write lock, this will fail
333+
* with SQLITE_BUSY error.
334+
*
327335
* @template T
328336
* @param {() => Promise<T>} callback - The callback to run in transaction
329337
* @returns {Promise<T>} A promise that resolves to the callback result
338+
* @see https://www.sqlite.org/isolation.html
330339
*/
331340
async transactionalize(callback) {
332341
await this.beginTransaction();
@@ -341,7 +350,10 @@ class SqliteDatabase {
341350
}
342351

343352
/**
344-
* Begin a transaction
353+
* Begin a transaction using BEGIN IMMEDIATE TRANSACTION
354+
*
355+
* This acquires a write lock immediately, preventing deadlocks by
356+
* failing fast if another connection holds the lock (SQLITE_BUSY).
345357
*
346358
* @returns {Promise<SqlRunResult>}
347359
*/

lib/sqlite3.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,22 @@ export class SqliteDatabase {
265265
serialize(callback?: () => void): void;
266266
parallelize(callback?: () => void): void;
267267

268+
/**
269+
* Run callback inside a database transaction.
270+
*
271+
* IMPORTANT: SQLite provides isolation between different database connections,
272+
* but NOT between operations on the same connection. For concurrent transactions
273+
* with proper isolation, use separate SqliteDatabase instances.
274+
*
275+
* Uses BEGIN IMMEDIATE TRANSACTION which acquires a write lock immediately.
276+
* @see https://www.sqlite.org/isolation.html
277+
*/
268278
transactionalize<T>(callback: () => Promise<T>): Promise<T>;
279+
280+
/**
281+
* Begin a transaction using BEGIN IMMEDIATE TRANSACTION.
282+
* Acquires write lock immediately - fails with SQLITE_BUSY if lock unavailable.
283+
*/
269284
beginTransaction(): Promise<void>;
270285
commitTransaction(): Promise<void>;
271286
rollbackTransaction(): Promise<void>;

test/transaction_isolation.test.js

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* Tests for transaction isolation behavior
3+
*
4+
* Key insight from SQLite documentation:
5+
* - Isolation EXISTS between different database connections
6+
* - NO isolation between operations on the SAME database connection
7+
*/
8+
9+
'use strict';
10+
11+
const assert = require('assert');
12+
const fs = require('fs');
13+
const path = require('path');
14+
const sqlite3 = require('..');
15+
const { SqliteDatabase } = sqlite3;
16+
const helper = require('./support/helper');
17+
18+
// Helper function to generate unique database paths
19+
let dbCounter = 0;
20+
function newDatabasePath() {
21+
const tmpDir = path.join(__dirname, 'tmp');
22+
if (!fs.existsSync(tmpDir)) {
23+
fs.mkdirSync(tmpDir, { recursive: true });
24+
}
25+
return path.join(tmpDir, `transaction_test_${Date.now()}_${++dbCounter}.db`);
26+
}
27+
28+
describe('Transaction Isolation', () => {
29+
describe('single connection behavior', () => {
30+
let db;
31+
32+
beforeEach(async () => {
33+
db = await SqliteDatabase.open(':memory:');
34+
await db.exec('CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)');
35+
});
36+
37+
afterEach(async () => {
38+
await db.close();
39+
});
40+
41+
it('should handle sequential transactions correctly', async () => {
42+
// Sequential transactions on same connection should work fine
43+
await db.transactionalize(async () => {
44+
await db.run("INSERT INTO test (value) VALUES ('tx1')");
45+
});
46+
47+
await db.transactionalize(async () => {
48+
await db.run("INSERT INTO test (value) VALUES ('tx2')");
49+
});
50+
51+
const rows = await db.all('SELECT * FROM test ORDER BY id');
52+
assert.strictEqual(rows.length, 2);
53+
assert.strictEqual(rows[0].value, 'tx1');
54+
assert.strictEqual(rows[1].value, 'tx2');
55+
});
56+
57+
it('should handle nested transactionalize calls (same connection)', async () => {
58+
// Note: This tests behavior but is NOT recommended practice
59+
// Nested transactions on same connection share the same transaction
60+
let insertedInNested = false;
61+
62+
await db.transactionalize(async () => {
63+
await db.run("INSERT INTO test (value) VALUES ('outer')");
64+
65+
// Inner transactionalize will start a new transaction
66+
// but since we're on the same connection, it's actually the same transaction
67+
try {
68+
await db.transactionalize(async () => {
69+
await db.run("INSERT INTO test (value) VALUES ('inner')");
70+
insertedInNested = true;
71+
});
72+
} catch (err) {
73+
// May fail because there's already an active transaction
74+
}
75+
});
76+
77+
const rows = await db.all('SELECT * FROM test ORDER BY id');
78+
// At minimum, outer should be inserted
79+
assert.strictEqual(rows.length >= 1, true);
80+
});
81+
82+
it('should demonstrate that concurrent operations on same connection are serialized', async () => {
83+
// JavaScript is single-threaded, so even with Promise.all,
84+
// operations on the same connection are serialized
85+
const results = await Promise.all([
86+
db.run("INSERT INTO test (value) VALUES ('concurrent1')"),
87+
db.run("INSERT INTO test (value) VALUES ('concurrent2')"),
88+
]);
89+
90+
const rows = await db.all('SELECT * FROM test ORDER BY id');
91+
assert.strictEqual(rows.length, 2);
92+
});
93+
});
94+
95+
describe('multiple connections behavior', () => {
96+
let db1, db2;
97+
98+
beforeEach(async () => {
99+
// Create a file-based database for cross-connection tests
100+
const dbPath = newDatabasePath();
101+
db1 = await SqliteDatabase.open(dbPath);
102+
db2 = await SqliteDatabase.open(dbPath);
103+
104+
await db1.exec('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, value TEXT)');
105+
// Clear any existing data
106+
await db1.exec('DELETE FROM test');
107+
});
108+
109+
afterEach(async () => {
110+
await db1.close();
111+
await db2.close();
112+
});
113+
114+
it('should provide isolation between concurrent transactions on different connections', async () => {
115+
// Start transaction on db1
116+
await db1.beginTransaction();
117+
118+
// Insert in db1's transaction (not committed yet)
119+
await db1.run("INSERT INTO test (value) VALUES ('from_db1')");
120+
121+
// db2 should NOT see uncommitted changes from db1
122+
const rowsBeforeCommit = await db2.all('SELECT * FROM test');
123+
assert.strictEqual(rowsBeforeCommit.length, 0, 'db2 should not see uncommitted changes');
124+
125+
// Commit db1's transaction
126+
await db1.commitTransaction();
127+
128+
// Now db2 should see the committed changes
129+
const rowsAfterCommit = await db2.all('SELECT * FROM test');
130+
assert.strictEqual(rowsAfterCommit.length, 1, 'db2 should see committed changes');
131+
assert.strictEqual(rowsAfterCommit[0].value, 'from_db1');
132+
});
133+
134+
it('should handle concurrent transactions on different connections', async () => {
135+
// NOTE: This test demonstrates that concurrent write transactions
136+
// on different connections to the same database will fail with SQLITE_BUSY
137+
// because BEGIN IMMEDIATE TRANSACTION acquires a write lock.
138+
// This is expected SQLite behavior
139+
140+
// Start transaction on db1 first
141+
await db1.beginTransaction();
142+
143+
// db2 trying to start a transaction while db1 has the lock should fail
144+
await assert.rejects(
145+
async () => await db2.beginTransaction(),
146+
/SQLITE_BUSY|database is locked/,
147+
'db2 should fail to start transaction while db1 has write lock'
148+
);
149+
150+
// db1 can still insert
151+
await db1.run("INSERT INTO test (value) VALUES ('from_db1')");
152+
153+
// Commit db1
154+
await db1.commitTransaction();
155+
156+
// Now db2 can start its transaction
157+
await db2.beginTransaction();
158+
await db2.run("INSERT INTO test (value) VALUES ('from_db2')");
159+
await db2.commitTransaction();
160+
161+
// Both should see all committed data
162+
const rowsFinal1 = await db1.all('SELECT * FROM test ORDER BY value');
163+
const rowsFinal2 = await db2.all('SELECT * FROM test ORDER BY value');
164+
165+
assert.strictEqual(rowsFinal1.length, 2);
166+
assert.strictEqual(rowsFinal2.length, 2);
167+
});
168+
169+
it('should handle rollback isolation between connections', async () => {
170+
// db1 starts and completes its transaction first
171+
await db1.beginTransaction();
172+
await db1.run("INSERT INTO test (value) VALUES ('from_db1')");
173+
await db1.rollbackTransaction();
174+
175+
// Now db2 can start its transaction
176+
await db2.beginTransaction();
177+
await db2.run("INSERT INTO test (value) VALUES ('from_db2')");
178+
await db2.commitTransaction();
179+
180+
// Both should only see db2's committed data (db1 was rolled back)
181+
const rows1 = await db1.all('SELECT * FROM test');
182+
const rows2 = await db2.all('SELECT * FROM test');
183+
184+
assert.strictEqual(rows1.length, 1);
185+
assert.strictEqual(rows2.length, 1);
186+
assert.strictEqual(rows1[0].value, 'from_db2');
187+
});
188+
189+
it('should demonstrate proper isolation with transactionalize on separate connections', async () => {
190+
// Run concurrent transactions on separate connections
191+
const results = await Promise.all([
192+
db1.transactionalize(async () => {
193+
await db1.run("INSERT INTO test (value) VALUES ('tx1')");
194+
// Small delay to increase chance of concurrent execution
195+
await new Promise(resolve => setTimeout(resolve, 10));
196+
return 'tx1_done';
197+
}),
198+
db2.transactionalize(async () => {
199+
await db2.run("INSERT INTO test (value) VALUES ('tx2')");
200+
await new Promise(resolve => setTimeout(resolve, 10));
201+
return 'tx2_done';
202+
}),
203+
]);
204+
205+
assert.deepStrictEqual(results, ['tx1_done', 'tx2_done']);
206+
207+
// Both transactions should have committed
208+
const rows = await db1.all('SELECT * FROM test ORDER BY value');
209+
assert.strictEqual(rows.length, 2);
210+
});
211+
});
212+
213+
describe('write lock contention', () => {
214+
let db1, db2;
215+
216+
beforeEach(async () => {
217+
const dbPath = newDatabasePath();
218+
db1 = await SqliteDatabase.open(dbPath);
219+
db2 = await SqliteDatabase.open(dbPath);
220+
221+
await db1.exec('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, value TEXT)');
222+
await db1.exec('DELETE FROM test');
223+
});
224+
225+
afterEach(async () => {
226+
await db1.close();
227+
await db2.close();
228+
});
229+
230+
it('should handle write lock contention with BEGIN IMMEDIATE', async () => {
231+
// BEGIN IMMEDIATE TRANSACTION acquires a write lock immediately
232+
// This prevents deadlocks by failing fast if lock is not available
233+
234+
await db1.beginTransaction(); // Uses BEGIN IMMEDIATE TRANSACTION
235+
236+
// db2 trying to start a transaction should wait or fail
237+
// depending on the busy timeout
238+
let db2Started = false;
239+
let db2Error = null;
240+
241+
const db2Promise = db2.beginTransaction()
242+
.then(() => {
243+
db2Started = true;
244+
})
245+
.catch(err => {
246+
db2Error = err;
247+
});
248+
249+
// Give db2 a chance to try
250+
await new Promise(resolve => setTimeout(resolve, 50));
251+
252+
// db1 should still have the lock
253+
await db1.run("INSERT INTO test (value) VALUES ('from_db1')");
254+
await db1.commitTransaction();
255+
256+
// Now db2 should be able to proceed
257+
await db2Promise;
258+
259+
if (db2Error) {
260+
// If db2 timed out waiting for lock, that's acceptable behavior
261+
assert.ok(db2Error.message.includes('locked') || db2Error.message.includes('busy'),
262+
`Expected lock-related error, got: ${db2Error.message}`);
263+
} else {
264+
// db2 got the lock after db1 committed
265+
assert.strictEqual(db2Started, true);
266+
}
267+
});
268+
});
269+
});

0 commit comments

Comments
 (0)