refs-by-files-spread.test.ts 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. /**
  2. * getUnresolvedReferencesByFiles must survive dense result sets (#1558).
  3. *
  4. * The input file-path list is chunked under SQLite's parameter limit, but the
  5. * ROWS a chunk returns are unbounded — and appending them with
  6. * `rows.push(...chunkRows)` passes every row as a call argument, so a dense
  7. * chunk (a recovery sync re-indexing many files at once, e.g. the #1541
  8. * self-heal) exceeded V8's argument limit and killed the whole sync with
  9. * "Maximum call stack size exceeded" after the store phase, leaving every
  10. * re-indexed file's references unresolved. Reproduced for real on a
  11. * cpython-stdlib-sized heal (919 files, 234k refs). The append is now a loop;
  12. * this pins it with a result set well past V8's argument ceiling (~124k).
  13. */
  14. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  15. import * as fs from 'node:fs';
  16. import * as path from 'node:path';
  17. import * as os from 'node:os';
  18. import { CodeGraph } from '../src';
  19. import type { UnresolvedReference } from '../src/types';
  20. describe('unresolved-ref loads with dense result sets (#1558)', () => {
  21. let dir: string;
  22. let cg: CodeGraph;
  23. beforeEach(async () => {
  24. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'refs-spread-'));
  25. fs.writeFileSync(path.join(dir, 'anchor.py'), 'def anchor():\n return 1\n');
  26. cg = await CodeGraph.init(dir);
  27. await cg.indexAll();
  28. });
  29. afterEach(() => {
  30. cg.destroy();
  31. fs.rmSync(dir, { recursive: true, force: true });
  32. });
  33. it('returns 200k pending refs from few files without exhausting the call stack', () => {
  34. const queries = (cg as unknown as {
  35. queries: {
  36. insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void;
  37. getUnresolvedReferencesByFiles(paths: string[]): UnresolvedReference[];
  38. };
  39. }).queries;
  40. const FILES = 200;
  41. const TOTAL = 200_000;
  42. const paths: string[] = Array.from({ length: FILES }, (_, i) => `src/f${i}.py`);
  43. // unresolved_refs.from_node_id is FK-constrained — anchor on a real node.
  44. const anchorId = cg.getNodesInFile('anchor.py')[0]!.id;
  45. const batch: UnresolvedReference[] = [];
  46. for (let i = 0; i < TOTAL; i++) {
  47. batch.push({
  48. fromNodeId: anchorId,
  49. referenceName: `ref_${i}`,
  50. referenceKind: 'call',
  51. line: (i % 1000) + 1,
  52. column: 0,
  53. filePath: paths[i % FILES]!,
  54. language: 'python',
  55. });
  56. if (batch.length === 20_000) {
  57. queries.insertUnresolvedRefsBatch(batch);
  58. batch.length = 0;
  59. }
  60. }
  61. if (batch.length > 0) queries.insertUnresolvedRefsBatch(batch);
  62. // All 200 paths fit in ONE SQLite parameter chunk, so a single query
  63. // returns all 200k rows — the exact shape that blew the argument limit.
  64. const rows = queries.getUnresolvedReferencesByFiles(paths);
  65. expect(rows.length).toBe(TOTAL);
  66. });
  67. });