db-perf.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. /**
  2. * DB Performance / Correctness Tests
  3. *
  4. * Regression tests for three changes:
  5. * 1. Batch `getNodesByIds` collapses graph-traversal N+1 reads.
  6. * 2. `insertNode` invalidates the LRU cache so INSERT OR REPLACE
  7. * doesn't serve a stale cached row on next `getNodeById`.
  8. * 3. `runMaintenance` runs `PRAGMA optimize` + `wal_checkpoint(PASSIVE)`
  9. * after indexAll/sync without throwing.
  10. * 4. `insertEdges` validates endpoints from the DB, not stale node cache.
  11. */
  12. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  13. import * as fs from 'fs';
  14. import * as path from 'path';
  15. import * as os from 'os';
  16. import { DatabaseConnection } from '../src/db';
  17. import { QueryBuilder } from '../src/db/queries';
  18. import { Node } from '../src/types';
  19. function makeNode(id: string, name = id): Node {
  20. return {
  21. id,
  22. kind: 'function',
  23. name,
  24. qualifiedName: name,
  25. filePath: 'a.ts',
  26. language: 'typescript',
  27. startLine: 1,
  28. endLine: 1,
  29. startColumn: 0,
  30. endColumn: 0,
  31. updatedAt: Date.now(),
  32. };
  33. }
  34. describe('getNodesByIds (batch lookup)', () => {
  35. let dir: string;
  36. let db: DatabaseConnection;
  37. let q: QueryBuilder;
  38. beforeEach(() => {
  39. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-batch-'));
  40. db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  41. q = new QueryBuilder(db.getDb());
  42. });
  43. afterEach(() => {
  44. db.close();
  45. if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  46. });
  47. it('returns a Map keyed by id, with one entry per existing node', () => {
  48. q.insertNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')]);
  49. const out = q.getNodesByIds(['n1', 'n2', 'n3']);
  50. expect(out.size).toBe(3);
  51. expect(out.get('n1')!.name).toBe('n1');
  52. expect(out.get('n3')!.name).toBe('n3');
  53. });
  54. it('omits missing IDs from the result map (no nulls, no exceptions)', () => {
  55. q.insertNodes([makeNode('n1'), makeNode('n2')]);
  56. const out = q.getNodesByIds(['n1', 'missing', 'n2']);
  57. expect(out.size).toBe(2);
  58. expect(out.has('missing')).toBe(false);
  59. expect(out.has('n1')).toBe(true);
  60. expect(out.has('n2')).toBe(true);
  61. });
  62. it('handles an empty input array', () => {
  63. expect(q.getNodesByIds([]).size).toBe(0);
  64. });
  65. it('handles batches over the SQLite parameter limit (chunking)', () => {
  66. // Insert 1500 nodes; the helper chunks at 500 internally.
  67. const nodes = Array.from({ length: 1500 }, (_, i) => makeNode(`n${i}`));
  68. q.insertNodes(nodes);
  69. const ids = nodes.map((n) => n.id);
  70. const out = q.getNodesByIds(ids);
  71. expect(out.size).toBe(1500);
  72. // Spot-check a few from the first / middle / last chunk.
  73. expect(out.has('n0')).toBe(true);
  74. expect(out.has('n750')).toBe(true);
  75. expect(out.has('n1499')).toBe(true);
  76. });
  77. it('serves cache hits from memory and queries only the misses', () => {
  78. q.insertNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')]);
  79. // Warm the cache for n1 only.
  80. q.getNodeById('n1');
  81. // Replace the underlying row to make a miss-vs-cache-hit detectable.
  82. db.getDb().prepare('UPDATE nodes SET name = ? WHERE id = ?').run('changed', 'n1');
  83. const out = q.getNodesByIds(['n1', 'n2']);
  84. // The cached n1 (still 'n1', not 'changed') must be returned.
  85. expect(out.get('n1')!.name).toBe('n1');
  86. expect(out.get('n2')!.name).toBe('n2');
  87. });
  88. });
  89. describe('deleteResolvedReferences (chunking)', () => {
  90. let dir: string;
  91. let db: DatabaseConnection;
  92. let q: QueryBuilder;
  93. beforeEach(() => {
  94. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-delref-'));
  95. db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  96. q = new QueryBuilder(db.getDb());
  97. });
  98. afterEach(() => {
  99. db.close();
  100. if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  101. });
  102. it('deletes unresolved refs for more ids than the SQLite parameter limit (#1001)', () => {
  103. // Regression: this method bound every id as one parameter in a single
  104. // IN (...), so passing more ids than SQLITE_MAX_VARIABLE_NUMBER (32766 on
  105. // the bundled node:sqlite) threw "too many SQL variables". Use 33000 to
  106. // clear that ceiling. from_node_id has a FK to nodes, so insert nodes first.
  107. const nodes = Array.from({ length: 33000 }, (_, i) => makeNode(`n${i}`));
  108. q.insertNodes(nodes);
  109. q.insertUnresolvedRefsBatch(
  110. nodes.map((n) => ({
  111. fromNodeId: n.id,
  112. referenceName: 'someName',
  113. referenceKind: 'calls',
  114. line: 1,
  115. column: 0,
  116. }))
  117. );
  118. expect(q.getUnresolvedReferencesCount()).toBe(33000);
  119. const ids = nodes.map((n) => n.id);
  120. expect(() => q.deleteResolvedReferences(ids)).not.toThrow();
  121. expect(q.getUnresolvedReferencesCount()).toBe(0);
  122. });
  123. it('handles an empty input array', () => {
  124. expect(() => q.deleteResolvedReferences([])).not.toThrow();
  125. });
  126. });
  127. describe('insertNode cache invalidation', () => {
  128. let dir: string;
  129. let db: DatabaseConnection;
  130. let q: QueryBuilder;
  131. beforeEach(() => {
  132. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-cache-'));
  133. db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  134. q = new QueryBuilder(db.getDb());
  135. });
  136. afterEach(() => {
  137. db.close();
  138. if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  139. });
  140. it('does not serve a stale cached node after INSERT OR REPLACE', () => {
  141. // Regression: insertNode (which uses INSERT OR REPLACE) used to skip
  142. // cache invalidation, so the next getNodeById returned the pre-replace
  143. // version until LRU eviction.
  144. const original = makeNode('n1', 'oldName');
  145. q.insertNode(original);
  146. const beforeReplace = q.getNodeById('n1');
  147. expect(beforeReplace!.name).toBe('oldName');
  148. // Replace via insertNode (the bug path).
  149. q.insertNode({ ...original, name: 'newName', updatedAt: Date.now() });
  150. const afterReplace = q.getNodeById('n1');
  151. expect(afterReplace!.name).toBe('newName');
  152. });
  153. });
  154. describe('insertEdges endpoint validation', () => {
  155. let dir: string;
  156. let db: DatabaseConnection;
  157. let q: QueryBuilder;
  158. beforeEach(() => {
  159. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-edges-'));
  160. db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  161. q = new QueryBuilder(db.getDb());
  162. });
  163. afterEach(() => {
  164. db.close();
  165. if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  166. });
  167. it('skips edges with missing endpoints instead of failing the whole batch', () => {
  168. q.insertNodes([makeNode('source'), makeNode('target'), makeNode('other')]);
  169. expect(() =>
  170. q.insertEdges([
  171. { source: 'source', target: 'target', kind: 'calls' },
  172. { source: 'source', target: 'missing-target', kind: 'calls' },
  173. { source: 'missing-source', target: 'other', kind: 'references' },
  174. ])
  175. ).not.toThrow();
  176. const edges = q.getOutgoingEdges('source');
  177. expect(edges).toHaveLength(1);
  178. expect(edges[0]).toMatchObject({ source: 'source', target: 'target', kind: 'calls' });
  179. });
  180. it('does not trust stale cached nodes when validating edge endpoints', () => {
  181. q.insertNodes([makeNode('source'), makeNode('target')]);
  182. expect(q.getNodeById('target')!.id).toBe('target');
  183. db.getDb().prepare('DELETE FROM nodes WHERE id = ?').run('target');
  184. expect(() =>
  185. q.insertEdges([{ source: 'source', target: 'target', kind: 'calls' }])
  186. ).not.toThrow();
  187. expect(q.getOutgoingEdges('source')).toEqual([]);
  188. });
  189. });
  190. describe('runMaintenance', () => {
  191. let dir: string;
  192. let db: DatabaseConnection;
  193. beforeEach(() => {
  194. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-maint-'));
  195. db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  196. });
  197. afterEach(() => {
  198. db.close();
  199. if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  200. });
  201. it('runs without throwing on a fresh database', () => {
  202. expect(() => db.runMaintenance()).not.toThrow();
  203. });
  204. it('runs without throwing after writes', () => {
  205. const q = new QueryBuilder(db.getDb());
  206. q.insertNodes([makeNode('n1'), makeNode('n2')]);
  207. expect(() => db.runMaintenance()).not.toThrow();
  208. });
  209. it('swallows failures rather than propagating (best-effort)', () => {
  210. // Close the DB so the underlying handle would normally throw on any
  211. // exec(). runMaintenance must still not propagate.
  212. db.close();
  213. expect(() => db.runMaintenance()).not.toThrow();
  214. });
  215. });