|
| 1 | +import test from "ava"; |
| 2 | +import crypto from 'crypto'; |
| 3 | +import fs from 'fs'; |
| 4 | + |
| 5 | +test.beforeEach(async (t) => { |
| 6 | + const [db, errorType, path] = await connect(); |
| 7 | + |
| 8 | + await db.exec(` |
| 9 | + DROP TABLE IF EXISTS users; |
| 10 | + CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT) |
| 11 | + `); |
| 12 | + const aliceId = generateUUID(); |
| 13 | + const bobId = generateUUID(); |
| 14 | + await db.exec( |
| 15 | + `INSERT INTO users (id, name, email) VALUES ('${aliceId}', 'Alice', 'alice@example.org')` |
| 16 | + ); |
| 17 | + await db.exec( |
| 18 | + `INSERT INTO users (id, name, email) VALUES ('${bobId}', 'Bob', 'bob@example.com')` |
| 19 | + ); |
| 20 | + t.context = { |
| 21 | + db, |
| 22 | + errorType, |
| 23 | + aliceId, |
| 24 | + bobId, |
| 25 | + path |
| 26 | + }; |
| 27 | +}); |
| 28 | + |
| 29 | +test("Concurrent reads", async (t) => { |
| 30 | + const db = t.context.db; |
| 31 | + const stmt = await db.prepare("SELECT * FROM users WHERE id = ?"); |
| 32 | + |
| 33 | + const promises = []; |
| 34 | + for (let i = 0; i < 100; i++) { |
| 35 | + promises.push(stmt.get(t.context.aliceId)); |
| 36 | + promises.push(stmt.get(t.context.bobId)); |
| 37 | + } |
| 38 | + |
| 39 | + const results = await Promise.all(promises); |
| 40 | + |
| 41 | + for (let i = 0; i < results.length; i++) { |
| 42 | + const result = results[i]; |
| 43 | + t.truthy(result); |
| 44 | + t.is(typeof result.name, 'string'); |
| 45 | + t.is(typeof result.email, 'string'); |
| 46 | + } |
| 47 | + cleanup(t.context); |
| 48 | +}); |
| 49 | + |
| 50 | +test("Concurrent writes", async (t) => { |
| 51 | + const db = t.context.db; |
| 52 | + |
| 53 | + await db.exec(` |
| 54 | + DROP TABLE IF EXISTS concurrent_users; |
| 55 | + CREATE TABLE concurrent_users ( |
| 56 | + id TEXT PRIMARY KEY, |
| 57 | + name TEXT, |
| 58 | + email TEXT |
| 59 | + ) |
| 60 | + `); |
| 61 | + |
| 62 | + const stmt = await db.prepare("INSERT INTO concurrent_users(id, name, email) VALUES (:id, :name, :email)"); |
| 63 | + |
| 64 | + const promises = []; |
| 65 | + for (let i = 0; i < 50; i++) { |
| 66 | + promises.push(stmt.run({ |
| 67 | + id: generateUUID(), |
| 68 | + name: `User${i}`, |
| 69 | + email: `user${i}@example.com` |
| 70 | + })); |
| 71 | + } |
| 72 | + |
| 73 | + await Promise.all(promises); |
| 74 | + |
| 75 | + const countStmt = await db.prepare("SELECT COUNT(*) as count FROM concurrent_users"); |
| 76 | + const result = await countStmt.get(); |
| 77 | + t.is(result.count, 50); |
| 78 | + |
| 79 | + cleanup(t.context); |
| 80 | +}); |
| 81 | + |
| 82 | +test("Concurrent transaction isolation", async (t) => { |
| 83 | + const db = t.context.db; |
| 84 | + |
| 85 | + await db.exec(` |
| 86 | + DROP TABLE IF EXISTS transaction_users; |
| 87 | + CREATE TABLE transaction_users ( |
| 88 | + id TEXT PRIMARY KEY, |
| 89 | + name TEXT, |
| 90 | + email TEXT |
| 91 | + ) |
| 92 | + `); |
| 93 | + |
| 94 | + const aliceId = generateUUID(); |
| 95 | + const bobId = generateUUID(); |
| 96 | + |
| 97 | + await db.exec(` |
| 98 | + INSERT INTO transaction_users (id, name, email) VALUES |
| 99 | + ('${aliceId}', 'Alice', 'alice@example.org'), |
| 100 | + ('${bobId}', 'Bob', 'bob@example.com') |
| 101 | + `); |
| 102 | + |
| 103 | + const updateUser = db.transaction(async (id, name, email) => { |
| 104 | + const stmt = await db.prepare("UPDATE transaction_users SET name = :name, email = :email WHERE id = :id"); |
| 105 | + await stmt.run({ id, name, email }); |
| 106 | + }); |
| 107 | + |
| 108 | + const promises = []; |
| 109 | + for (let i = 0; i < 10; i++) { |
| 110 | + promises.push(updateUser(aliceId, `Alice${i}`, `alice${i}@example.org`)); |
| 111 | + promises.push(updateUser(bobId, `Bob${i}`, `bob${i}@example.com`)); |
| 112 | + } |
| 113 | + |
| 114 | + await Promise.all(promises); |
| 115 | + |
| 116 | + const stmt = await db.prepare("SELECT * FROM transaction_users ORDER BY name"); |
| 117 | + const results = await stmt.all(); |
| 118 | + t.is(results.length, 2); |
| 119 | + t.truthy(results[0].name.startsWith('Alice')); |
| 120 | + t.truthy(results[1].name.startsWith('Bob')); |
| 121 | + |
| 122 | + cleanup(t.context); |
| 123 | +}); |
| 124 | + |
| 125 | +test("Concurrent reads and writes", async (t) => { |
| 126 | + const db = t.context.db; |
| 127 | + |
| 128 | + await db.exec(` |
| 129 | + DROP TABLE IF EXISTS mixed_users; |
| 130 | + CREATE TABLE mixed_users ( |
| 131 | + id TEXT PRIMARY KEY, |
| 132 | + name TEXT, |
| 133 | + email TEXT |
| 134 | + ) |
| 135 | + `); |
| 136 | + |
| 137 | + const aliceId = generateUUID(); |
| 138 | + await db.exec(` |
| 139 | + INSERT INTO mixed_users (id, name, email) VALUES |
| 140 | + ('${aliceId}', 'Alice', 'alice@example.org') |
| 141 | + `); |
| 142 | + |
| 143 | + const readStmt = await db.prepare("SELECT * FROM mixed_users WHERE id = ?"); |
| 144 | + const writeStmt = await db.prepare("INSERT INTO mixed_users(id, name, email) VALUES (:id, :name, :email)"); |
| 145 | + |
| 146 | + const promises = []; |
| 147 | + for (let i = 0; i < 20; i++) { |
| 148 | + promises.push(readStmt.get(aliceId)); |
| 149 | + writeStmt.run({ |
| 150 | + id: generateUUID(), |
| 151 | + name: `User${i}`, |
| 152 | + email: `user${i}@example.com` |
| 153 | + }); |
| 154 | + } |
| 155 | + await Promise.all(promises); |
| 156 | + |
| 157 | + const countStmt = await db.prepare("SELECT COUNT(*) as count FROM mixed_users"); |
| 158 | + const result = await countStmt.get(); |
| 159 | + t.is(result.count, 21); // 1 initial + 20 new records |
| 160 | + |
| 161 | + await cleanup(t.context); |
| 162 | +}); |
| 163 | + |
| 164 | +test("Concurrent operations with timeout should handle busy database", async (t) => { |
| 165 | + const timeout = 1000; |
| 166 | + const path = `test-${crypto.randomBytes(8).toString('hex')}.db`; |
| 167 | + const [conn1] = await connect(path); |
| 168 | + const [conn2] = await connect(path, { timeout }); |
| 169 | + |
| 170 | + await conn1.exec("CREATE TABLE t(id TEXT PRIMARY KEY, x INTEGER)"); |
| 171 | + await conn1.exec("BEGIN IMMEDIATE"); |
| 172 | + await conn1.exec(`INSERT INTO t VALUES ('${generateUUID()}', 1)`); |
| 173 | + |
| 174 | + const start = Date.now(); |
| 175 | + try { |
| 176 | + await conn2.exec(`INSERT INTO t VALUES ('${generateUUID()}', 2)`); |
| 177 | + t.fail("Should have thrown SQLITE_BUSY error"); |
| 178 | + } catch (e) { |
| 179 | + t.is(e.code, "SQLITE_BUSY"); |
| 180 | + const end = Date.now(); |
| 181 | + const elapsed = end - start; |
| 182 | + t.true(elapsed > timeout / 2, "Timeout should be respected"); |
| 183 | + } |
| 184 | + |
| 185 | + conn1.close(); |
| 186 | + conn2.close(); |
| 187 | + // FIXME: Fails on Windows because file is still busy. |
| 188 | + // fs.unlinkSync(path); |
| 189 | +}); |
| 190 | + |
| 191 | + |
| 192 | +const connect = async (path_opt, options = {}) => { |
| 193 | + const path = path_opt ?? `test-${crypto.randomBytes(8).toString('hex')}.db`; |
| 194 | + const x = await import("libsql/promise"); |
| 195 | + const db = new x.default(process.env.LIBSQL_DATABASE ?? path, options); |
| 196 | + return [db, x.SqliteError, path]; |
| 197 | +}; |
| 198 | + |
| 199 | +const cleanup = async (context) => { |
| 200 | + context.db.close(); |
| 201 | + // FIXME: Fails on Windows because file is still busy. |
| 202 | + // fs.unlinkSync(context.path); |
| 203 | +}; |
| 204 | + |
| 205 | +const generateUUID = () => { |
| 206 | + return crypto.randomUUID(); |
| 207 | +}; |
0 commit comments