1
0

batched-ref-cleanup.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. /**
  2. * Batched resolution cleanup precision (#1269)
  3. *
  4. * Post-batch cleanup used to delete resolved refs (and park failed ones) by
  5. * (from_node_id, reference_name, reference_kind) — no line/col. When one
  6. * caller contains several call sites to the SAME callee and a batch boundary
  7. * splits them, resolving the first batch's sites deleted every row with that
  8. * key, including sibling rows in later batches that were never attempted —
  9. * their edges were silently never created. Observed on nlohmann/json:
  10. * `write_cbor` calls `to_char_type` at 11 lines; the batch boundary
  11. * deterministically dropped the last site's edge.
  12. *
  13. * Cleanup now targets the exact `unresolved_refs.id` for DB-loaded refs, with
  14. * the key-tuple delete kept only as the fallback for hand-built refs (public
  15. * resolveAndPersist API) that carry no row id.
  16. */
  17. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  18. import * as fs from 'fs';
  19. import * as path from 'path';
  20. import * as os from 'os';
  21. import { DatabaseConnection } from '../src/db';
  22. import { QueryBuilder } from '../src/db/queries';
  23. import { createResolver } from '../src/resolution';
  24. import { Node, UnresolvedReference } from '../src/types';
  25. function makeNode(id: string, name: string, kind: Node['kind'], filePath: string, startLine: number): Node {
  26. return {
  27. id,
  28. kind,
  29. name,
  30. qualifiedName: name,
  31. filePath,
  32. language: 'typescript',
  33. startLine,
  34. endLine: startLine + 2,
  35. startColumn: 0,
  36. endColumn: 0,
  37. updatedAt: Date.now(),
  38. };
  39. }
  40. function makeRef(fromNodeId: string, name: string, line: number): UnresolvedReference {
  41. return {
  42. fromNodeId,
  43. referenceName: name,
  44. referenceKind: 'calls',
  45. line,
  46. column: 2,
  47. filePath: 'caller.ts',
  48. language: 'typescript',
  49. };
  50. }
  51. describe('Batched ref cleanup precision (#1269)', () => {
  52. let dir: string;
  53. let db: DatabaseConnection;
  54. let q: QueryBuilder;
  55. beforeEach(() => {
  56. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-refcleanup-'));
  57. db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
  58. q = new QueryBuilder(db.getDb());
  59. // The files the refs/nodes point at must exist for resolution context.
  60. fs.writeFileSync(path.join(dir, 'caller.ts'), 'callee();\ncallee();\ncallee();\ncallee();\ncallee();\n');
  61. fs.writeFileSync(path.join(dir, 'callee.ts'), 'export function callee() {}\n');
  62. q.insertNode(makeNode('fn:caller', 'caller', 'function', 'caller.ts', 1));
  63. q.insertNode(makeNode('fn:callee', 'callee', 'function', 'callee.ts', 1));
  64. });
  65. afterEach(() => {
  66. db.close();
  67. fs.rmSync(dir, { recursive: true, force: true });
  68. });
  69. it('creates an edge for EVERY same-named call site when the sites straddle a batch boundary', async () => {
  70. // 5 call sites, batch size 2 → boundaries after sites 2 and 4. With the
  71. // key-tuple delete, batch 1's cleanup removed ALL five rows and only 2
  72. // edges ever existed.
  73. const lines = [1, 2, 3, 4, 5];
  74. q.insertUnresolvedRefsBatch(lines.map((line) => makeRef('fn:caller', 'callee', line)));
  75. expect(q.getUnresolvedReferencesCount()).toBe(5);
  76. const resolver = createResolver(dir, q);
  77. await resolver.resolveAndPersistBatched(undefined, 2);
  78. const edges = q
  79. .getOutgoingEdges('fn:caller')
  80. .filter((e) => e.kind === 'calls' && e.target === 'fn:callee');
  81. expect(edges.map((e) => e.line).sort()).toEqual(lines);
  82. // Every processed row left the pending set (drain terminated normally).
  83. expect(q.getUnresolvedReferencesCount()).toBe(0);
  84. });
  85. it('parks EVERY unresolvable same-named site as failed only after its own attempt', async () => {
  86. // 4 sites calling a name with no definition, batch size 2. Both halves
  87. // must drain to status='failed' (previously batch 1's key-tuple update
  88. // also flipped batch 2's rows before they were attempted — same outcome
  89. // here, but the loop must still terminate and leave nothing pending).
  90. q.insertUnresolvedRefsBatch([1, 2, 3, 4].map((line) => makeRef('fn:caller', 'missingCallee', line)));
  91. const resolver = createResolver(dir, q);
  92. await resolver.resolveAndPersistBatched(undefined, 2);
  93. expect(q.getUnresolvedReferencesCount()).toBe(0); // nothing pending
  94. const failed = q.getUnresolvedReferences().filter((r) => r.referenceName === 'missingCallee');
  95. expect(failed).toHaveLength(4); // all parked, none deleted
  96. });
  97. it('hand-built refs without a rowId still clean up through the key fallback', () => {
  98. // Public resolveAndPersist API: refs built in memory (no rowId) that also
  99. // exist as DB rows — the legacy key-tuple delete must still clear them.
  100. q.insertUnresolvedRefsBatch([makeRef('fn:caller', 'callee', 1)]);
  101. const resolver = createResolver(dir, q);
  102. const inMemory = makeRef('fn:caller', 'callee', 1); // no rowId
  103. const result = resolver.resolveAndPersist([inMemory]);
  104. expect(result.resolved).toHaveLength(1);
  105. expect(q.getUnresolvedReferencesCount()).toBe(0);
  106. });
  107. });