Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- Callers and impact analysis no longer silently under-count a function that calls the same callee many times. When one caller contained several call sites to the same callee and an internal resolution batch boundary happened to split them, cleanup after the first batch removed the later sites' pending rows before they were ever attempted — their edges were never created, deterministically, and which edges went missing shifted with unrelated changes to the project's total reference count. Post-pass cleanup now targets the exact database row each processed reference came from. Found while validating the operator-call fix on nlohmann/json, where `write_cbor`'s 11 calls to `to_char_type` indexed as 10. (#1269)
- C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247)
- `codegraph sync` no longer fails with "Maximum call stack size exceeded" on large projects. When a sync re-checked a big set of files at once — a fresh index, a large changelist, or the first sync after switching branches — CodeGraph gathered the pending (and retry) references for those files in a way that broke down once a single batch reached into the hundreds of thousands of references, aborting the whole sync before it could finish. Syncs of any size now complete.

## [1.4.1] - 2026-07-10

Expand Down
82 changes: 82 additions & 0 deletions __tests__/db-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,85 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', ()
fs.rmSync(dir, { recursive: true, force: true });
});
});

describe('unresolved-ref readers do not overflow the argument stack (large result sets)', () => {
let dir: string;
let db: DatabaseConnection;
let q: QueryBuilder;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-refspread-'));
db = DatabaseConnection.initialize(path.join(dir, 'test.db'));
q = new QueryBuilder(db.getDb());
});

afterEach(() => {
db.close();
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});

it('getUnresolvedReferencesByFiles: one file with more pending refs than the spread limit', () => {
// Regression: the method chunks the file-PATH list under SQLite's parameter
// limit, then collected the matched rows with `rows.push(...chunkRows)` —
// spreading every matched row as a separate call argument. On a large
// `codegraph sync` a single chunk of files matches hundreds of thousands of
// pending refs, which blew V8's argument-stack limit with "Maximum call
// stack size exceeded" (the arg-spread ceiling is ~125k). All refs here
// share ONE file_path so a single chunk returns the whole set; from_node_id
// has a FK to nodes, so a backing node must exist first.
const COUNT = 130_000; // just past the arg-spread ceiling
q.insertNode(makeNode('n1'));
q.insertUnresolvedRefsBatch(
Array.from({ length: COUNT }, (_, i) => ({
fromNodeId: 'n1',
referenceName: `ref${i}`,
referenceKind: 'calls' as const,
line: i + 1,
column: 0,
filePath: 'big.ts',
language: 'typescript' as const,
}))
);

let refs: ReturnType<typeof q.getUnresolvedReferencesByFiles> = [];
expect(() => {
refs = q.getUnresolvedReferencesByFiles(['big.ts']);
}).not.toThrow();
expect(refs.length).toBe(COUNT);
});

it('getRetryableFailedReferences: one name-chunk matching more failed refs than the spread limit', () => {
// Same regression, second reader (#1240 retry path). Pass 2 chunks the NAME
// list under the parameter limit, then did `rows.push(...chunkRows)`. Each
// name stays under perNameCeiling, but a single chunk of names collectively
// matches far more failed rows than the arg-spread ceiling, so the spread
// overflowed the stack. Here 400 distinct name tails × 400 failed refs each
// (each under the 500 ceiling) all fall in one name-chunk → 160k rows.
const TAILS = 400;
const PER_TAIL = 400; // < perNameCeiling(500) so every tail survives pass 1
q.insertNode(makeNode('n1'));

const refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: 'calls'; line: number; column: number }> = [];
for (let t = 0; t < TAILS; t++) {
for (let j = 0; j < PER_TAIL; j++) {
// A plain name (no '.'/'::') is its own name_tail, so referenceName === tail.
refs.push({ fromNodeId: 'n1', referenceName: `tail${t}`, referenceKind: 'calls', line: j + 1, column: 0 });
}
}
q.insertUnresolvedRefsBatch(refs);

// Park them all as status='failed' with their name_tail, so the retry reader
// (which only sees failed rows) returns them. One mark per distinct tuple
// flips all same-tuple rows.
q.markReferencesFailed(
Array.from({ length: TAILS }, (_, t) => ({ fromNodeId: 'n1', referenceName: `tail${t}`, referenceKind: 'calls' }))
);

const names = Array.from({ length: TAILS }, (_, t) => `tail${t}`);
let retry: ReturnType<typeof q.getRetryableFailedReferences> = [];
expect(() => {
retry = q.getRetryableFailedReferences(names);
}).not.toThrow();
expect(retry.length).toBe(TAILS * PER_TAIL);
});
});
14 changes: 12 additions & 2 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1945,7 +1945,12 @@ export class QueryBuilder {
const chunkRows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE status = 'pending' AND file_path IN (${placeholders})`)
.all(...chunk) as UnresolvedRefRow[];
rows.push(...chunkRows);
// Append without spreading: `rows.push(...chunkRows)` passes every row as a
// separate argument, and a 500-file chunk of a large repo matches tens of
// thousands of pending refs — enough to blow V8's argument-stack limit with
// "Maximum call stack size exceeded" on `codegraph sync`. Chunking the
// file-path IN-list bounds the SQL params, NOT the returned row count.
for (const row of chunkRows) rows.push(row);
}

return rows.map((row) => ({
Expand Down Expand Up @@ -2098,7 +2103,12 @@ export class QueryBuilder {
const chunkRows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE status = 'failed' AND name_tail IN (${placeholders})`)
.all(...chunk) as UnresolvedRefRow[];
rows.push(...chunkRows);
// Append without spreading — see getUnresolvedReferencesByFiles: chunking
// the name IN-list bounds the SQL params, not the row count. One chunk of
// names, each just under `perNameCeiling`, can still match >100k failed
// rows, and `rows.push(...chunkRows)` would overflow V8's argument stack
// with "Maximum call stack size exceeded".
for (const row of chunkRows) rows.push(row);
}

return rows.map((row) => ({
Expand Down