concurrent-locking.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. /**
  2. * Issue #238 — "database is locked" on concurrent MCP tool calls.
  3. *
  4. * The reporter's suggested fix (enable WAL / busy_timeout) was already in place,
  5. * so these tests pin the ACTUAL fixes:
  6. * 1. busy_timeout is a bounded few-second wait (not a 2-minute hang) and WAL is
  7. * active on the native backend — the property concurrent reads rely on.
  8. * 2. The MCP ToolHandler reuses the default instance when a tool passes a
  9. * projectPath pointing at the default project, instead of opening a SECOND
  10. * connection to the same DB (the lock amplifier).
  11. * 3. The wasm backend (which can't do WAL) retries reads on SQLITE_BUSY.
  12. */
  13. import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
  14. import * as fs from 'fs';
  15. import * as path from 'path';
  16. import * as os from 'os';
  17. import CodeGraph from '../src';
  18. import { ToolHandler } from '../src/mcp/tools';
  19. import { DatabaseConnection } from '../src/db';
  20. import { withBusyRetry, isDatabaseLockedError } from '../src/db/sqlite-adapter';
  21. // The bundled wasm fallback backend — the one the actual reporters run on and the
  22. // only one without WAL. Loaded the same way the adapter loads it (CJS require).
  23. // eslint-disable-next-line @typescript-eslint/no-require-imports
  24. const { Database: WasmDatabase } = require('node-sqlite3-wasm');
  25. /** Normalize a PRAGMA read across backends (array | object | scalar) to a value. */
  26. function pragmaValue(raw: unknown, key: string): unknown {
  27. const row = Array.isArray(raw) ? raw[0] : raw;
  28. if (row !== null && typeof row === 'object') return (row as Record<string, unknown>)[key];
  29. return row;
  30. }
  31. describe('issue #238 — connection PRAGMAs (#1)', () => {
  32. let dir: string;
  33. let conn: DatabaseConnection;
  34. beforeAll(() => {
  35. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-pragma-'));
  36. conn = DatabaseConnection.initialize(path.join(dir, 'codegraph.db'));
  37. });
  38. afterAll(() => {
  39. conn.close();
  40. fs.rmSync(dir, { recursive: true, force: true });
  41. });
  42. it('uses a bounded busy_timeout, not the old 2-minute hang', () => {
  43. const ms = Number(pragmaValue(conn.getDb().pragma('busy_timeout'), 'timeout'));
  44. expect(ms).toBeGreaterThan(0);
  45. expect(ms).toBeLessThanOrEqual(30000); // far below the old 120000
  46. });
  47. it('runs WAL on native (the mode that lets readers proceed during a write)', () => {
  48. const mode = String(pragmaValue(conn.getDb().pragma('journal_mode'), 'journal_mode')).toLowerCase();
  49. // Native supports WAL; the wasm fallback is forced to DELETE (no WAL).
  50. expect(mode).toBe(conn.getBackend() === 'wasm' ? 'delete' : 'wal');
  51. });
  52. it('getJournalMode() surfaces the effective mode for status triage', () => {
  53. // The conclusive data point for triaging "database is locked": 'wal' means
  54. // readers can't be blocked by a writer; anything else means they can.
  55. const mode = conn.getJournalMode();
  56. expect(mode).toBe(conn.getBackend() === 'wasm' ? 'delete' : 'wal');
  57. });
  58. });
  59. describe('issue #238 — native WAL lets a reader proceed during a writer', () => {
  60. let dir: string;
  61. beforeAll(() => {
  62. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-wal-'));
  63. });
  64. afterAll(() => {
  65. fs.rmSync(dir, { recursive: true, force: true });
  66. });
  67. it('a read on a 2nd connection succeeds while a writer holds the lock', () => {
  68. const dbPath = path.join(dir, 'codegraph.db');
  69. const writer = DatabaseConnection.initialize(dbPath);
  70. // This property only holds under WAL; on the wasm fallback (DELETE) an
  71. // EXCLUSIVE writer correctly blocks readers, so the assertion is native-only.
  72. if (writer.getBackend() !== 'native') {
  73. writer.close();
  74. return;
  75. }
  76. const reader = DatabaseConnection.open(dbPath);
  77. try {
  78. writer.getDb().prepare('BEGIN EXCLUSIVE').run(); // hard write lock, held open
  79. const t0 = Date.now();
  80. const row = reader.getDb().prepare('SELECT COUNT(*) AS c FROM nodes').get() as { c: number };
  81. const waited = Date.now() - t0;
  82. expect(row.c).toBe(0);
  83. expect(waited).toBeLessThan(1000); // proceeds immediately, no busy wait
  84. } finally {
  85. try { writer.getDb().prepare('COMMIT').run(); } catch { /* ignore */ }
  86. reader.close();
  87. writer.close();
  88. }
  89. });
  90. });
  91. describe('issue #238 — ToolHandler reuses the default instance (#2)', () => {
  92. let dir: string;
  93. let cg: CodeGraph;
  94. let root: string;
  95. let handler: ToolHandler;
  96. beforeAll(async () => {
  97. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-tools-'));
  98. fs.writeFileSync(path.join(dir, 'a.ts'), 'export function helper(): number { return 1; }\n');
  99. fs.writeFileSync(
  100. path.join(dir, 'b.ts'),
  101. "import { helper } from './a';\nexport function main(): number { return helper(); }\n"
  102. );
  103. cg = await CodeGraph.init(dir, { index: true });
  104. root = cg.getProjectRoot();
  105. handler = new ToolHandler(cg);
  106. });
  107. afterAll(() => {
  108. cg.close();
  109. fs.rmSync(dir, { recursive: true, force: true });
  110. });
  111. it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => {
  112. const openSpy = vi.spyOn(CodeGraph, 'openSync');
  113. try {
  114. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  115. const resolved = (handler as any).getCodeGraph(root);
  116. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  117. const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist'));
  118. expect(resolved).toBe(cg);
  119. expect(nested).toBe(cg); // a sub-path resolves up to the same default project
  120. expect(openSpy).not.toHaveBeenCalled(); // no second connection opened
  121. } finally {
  122. openSpy.mockRestore();
  123. }
  124. });
  125. it('concurrent read tool calls (mixed projectPath) all succeed without "database is locked"', async () => {
  126. const openSpy = vi.spyOn(CodeGraph, 'openSync');
  127. try {
  128. const calls: Promise<{ content: Array<{ text: string }>; isError?: boolean }>[] = [
  129. handler.execute('codegraph_search', { query: 'helper' }),
  130. handler.execute('codegraph_search', { query: 'helper', projectPath: root }),
  131. handler.execute('codegraph_callers', { symbol: 'helper', projectPath: root }),
  132. handler.execute('codegraph_callees', { symbol: 'main' }),
  133. handler.execute('codegraph_files', { projectPath: root }),
  134. handler.execute('codegraph_status', { projectPath: root }),
  135. ];
  136. const results = await Promise.all(calls);
  137. for (const r of results) {
  138. expect(r.isError).not.toBe(true);
  139. expect(r.content[0]?.text ?? '').not.toMatch(/database is locked/i);
  140. }
  141. // Passing the default project's own path must not open a second connection.
  142. expect(openSpy).not.toHaveBeenCalled();
  143. } finally {
  144. openSpy.mockRestore();
  145. }
  146. });
  147. });
  148. describe('issue #238 — withBusyRetry / isDatabaseLockedError (#3)', () => {
  149. const locked = () => Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
  150. it('retries a locked read and then succeeds', () => {
  151. const sleeps: number[] = [];
  152. let calls = 0;
  153. const result = withBusyRetry(
  154. () => {
  155. calls++;
  156. if (calls < 3) throw locked();
  157. return 'ok';
  158. },
  159. { attempts: 5, backoffMs: [10, 20], sleep: (ms) => sleeps.push(ms) }
  160. );
  161. expect(result).toBe('ok');
  162. expect(calls).toBe(3);
  163. expect(sleeps).toEqual([10, 20]); // backed off between the two retries
  164. });
  165. it('gives up after the attempt budget and rethrows the lock error', () => {
  166. let calls = 0;
  167. expect(() =>
  168. withBusyRetry(
  169. () => { calls++; throw locked(); },
  170. { attempts: 3, backoffMs: [0], sleep: () => {} }
  171. )
  172. ).toThrow(/database is locked/i);
  173. expect(calls).toBe(3);
  174. });
  175. it('does not retry a non-lock error', () => {
  176. let calls = 0;
  177. expect(() =>
  178. withBusyRetry(
  179. () => { calls++; throw new Error('no such table: nodes'); },
  180. { attempts: 5, sleep: () => {} }
  181. )
  182. ).toThrow(/no such table/);
  183. expect(calls).toBe(1);
  184. });
  185. it('isDatabaseLockedError recognizes lock errors across backends', () => {
  186. expect(isDatabaseLockedError(Object.assign(new Error('x'), { code: 'SQLITE_BUSY' }))).toBe(true);
  187. expect(isDatabaseLockedError(Object.assign(new Error('x'), { code: 'SQLITE_LOCKED' }))).toBe(true);
  188. expect(isDatabaseLockedError(new Error('database is locked'))).toBe(true);
  189. expect(isDatabaseLockedError(new Error('database is busy'))).toBe(true);
  190. expect(isDatabaseLockedError(new Error('SQLITE_BUSY: database is locked'))).toBe(true);
  191. expect(isDatabaseLockedError(new Error('no such column'))).toBe(false);
  192. expect(isDatabaseLockedError(null)).toBe(false);
  193. });
  194. });
  195. describe('issue #238 — wasm backend rides out a REAL lock via retry (#3, end-to-end)', () => {
  196. // Exercises an actual node-sqlite3-wasm connection against a real held write
  197. // lock — the path the reporters are on. Native (WAL) never reaches this code,
  198. // so it cannot be covered by the native CI backend; we drive wasm directly.
  199. let dir: string;
  200. let dbPath: string;
  201. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  202. let writer: any;
  203. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  204. let reader: any;
  205. beforeAll(() => {
  206. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg238-wasm-'));
  207. dbPath = path.join(dir, 'codegraph.db');
  208. const seed = new WasmDatabase(dbPath);
  209. seed.exec('PRAGMA journal_mode = DELETE'); // what the adapter forces for wasm (no WAL)
  210. seed.exec('CREATE TABLE nodes(id INTEGER PRIMARY KEY, name TEXT)');
  211. seed.exec("INSERT INTO nodes(name) VALUES ('seed')");
  212. seed.close();
  213. });
  214. afterAll(() => {
  215. fs.rmSync(dir, { recursive: true, force: true });
  216. });
  217. beforeEach(() => {
  218. writer = new WasmDatabase(dbPath);
  219. writer.exec('BEGIN EXCLUSIVE'); // real, held write lock
  220. writer.exec("INSERT INTO nodes(name) VALUES ('writer')");
  221. reader = new WasmDatabase(dbPath); // separate connection, no busy wait
  222. });
  223. afterEach(() => {
  224. try { reader.close(); } catch { /* ignore */ }
  225. try { writer.close(); } catch { /* ignore */ }
  226. });
  227. it('precondition: a wasm read hits a real lock while a writer holds EXCLUSIVE', () => {
  228. expect(() => reader.get('SELECT COUNT(*) AS c FROM nodes')).toThrow(/lock|busy/i);
  229. });
  230. it('withBusyRetry rides out a writer that clears mid-wait → the read succeeds', () => {
  231. let released = false;
  232. // The injected sleep stands in for the gap during which a cross-process
  233. // writer finishes; we release the held lock on the first retry. This proves
  234. // the wasm read path recovers instead of surfacing "database is locked".
  235. const row = withBusyRetry(
  236. () => reader.get('SELECT COUNT(*) AS c FROM nodes') as { c: number },
  237. {
  238. attempts: 4,
  239. backoffMs: [1],
  240. sleep: () => { if (!released) { writer.exec('COMMIT'); released = true; } },
  241. }
  242. );
  243. expect(released).toBe(true); // the first attempt really did hit the lock and retry
  244. expect(row.c).toBe(2); // seed + writer, visible once the writer committed
  245. });
  246. it('exhausting retries against a writer that never clears still throws a lock error', () => {
  247. expect(() =>
  248. withBusyRetry(
  249. () => reader.get('SELECT COUNT(*) AS c FROM nodes'),
  250. { attempts: 3, backoffMs: [1], sleep: () => { /* writer never releases */ } }
  251. )
  252. ).toThrow(/lock|busy/i);
  253. });
  254. });