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 @@ -154,6 +154,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
- A full `codegraph index` now ends with complete planner statistics, so on a repository with one huge generated file (an Android `R.java`, a bundled `.d.ts`) `codegraph_explore` no longer takes a minute or more per call. (#2571)

## [1.6.2] - 2026-10-03

Expand Down
52 changes: 52 additions & 0 deletions __tests__/index-full-analyze-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* A full index leaves the planner exact statistics (#2571).
*
* The maintenance after a bulk write ran `PRAGMA optimize` under
* `analysis_limit=1000`: statistics from the first rows of each index. On a
* repository with one generated file of thousands of symbols (an Android
* `R.java`, a bundled `.d.ts`) that sample misses the big file, the planner
* takes every file for a few symbols, and the dominant-file query behind
* every `codegraph_explore` is planned as a scan of each file's symbols per
* symbol — ~100 s per explore on a 280k-node index, 1.5 s with exact
* statistics. A full index now ends with a full `ANALYZE`; a sync keeps the
* incremental `optimize`, which re-analyzes only a table that grew by a lot.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DatabaseConnection } from '../src/db';

describe('#2571 — a full index leaves the planner exact statistics', () => {
it('runMaintenance after a full index analyzes every row, not a sample', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-analyze-'));
const db = DatabaseConnection.initialize(path.join(dir, 'codegraph.db'));
try {
const raw = db.getDb();
const insert = raw.prepare(
`INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, updated_at)
VALUES (?, 'constant', ?, ?, ?, 'java', 1, 1, 0, 0, 0)`,
);
// One generated file of 3,000 symbols beside 3,000 files of one: two symbols per file on average.
// The big file sorts after the small ones, where a sample of the index never reaches it.
raw.exec('BEGIN');
for (let i = 0; i < 3000; i++) {
insert.run(`big-${i}`, `R${i}`, `R.R${i}`, 'zz/R.java');
insert.run(`one-${i}`, `f${i}`, `f${i}`, `src/${String(i).padStart(4, '0')}.java`);
}
raw.exec('COMMIT');
const stat = (): string | undefined =>
(raw.prepare(`SELECT stat FROM sqlite_stat1 WHERE idx = 'idx_nodes_file_path'`).get() as { stat: string } | undefined)?.stat;

// The statistics a sync's maintenance leaves: a sample of the first rows, which misses the big file.
await db.runMaintenance();
expect(stat()).toBe('6000 1');

await db.runMaintenance({ fullAnalyze: true });
expect(stat()).toBe('6000 2');
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
21 changes: 17 additions & 4 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,16 @@ export class DatabaseConnection {
* tables whose row counts changed materially since the last
* ANALYZE. Without it, the query planner has no statistics on the
* freshly-bulk-loaded tables and can pick suboptimal indexes.
* After a FULL index (`fullAnalyze`) it is a full `ANALYZE` instead:
* the statistics `optimize` gathers under `analysis_limit` come from
* the first rows of each index, and on a repository with one
* generated file of thousands of symbols (an Android `R.java`, a
* bundled `.d.ts`) that sample misses the big file. The planner
* then takes every file for a few symbols, and the dominant-file query
* behind every explore is planned as a scan of each file's symbols
* per symbol — ~100 s per explore on a 280k-node index, 1.5 s with
* exact statistics (#2571). A full ANALYZE of a 1 GB index is under
* a second, once, at the end of a run that took minutes.
*
* - `PRAGMA wal_checkpoint(PASSIVE)` — fold pending WAL pages back
* into the main database file so the WAL file doesn't grow
Expand All @@ -769,17 +779,20 @@ export class DatabaseConnection {
* and SKIPS the checkpoint (the final close() checkpoints after the
* CLI has already disarmed its watchdog).
*/
async runMaintenance(): Promise<void> {
async runMaintenance(options: { fullAnalyze?: boolean } = {}): Promise<void> {
const analyze = options.fullAnalyze
? ['PRAGMA analysis_limit=0', 'ANALYZE']
: ['PRAGMA analysis_limit=1000', 'PRAGMA optimize'];
// In-memory / test databases: nothing worth a worker round-trip.
if (!this.dbPath || this.dbPath === ':memory:') {
try { this.db.exec('PRAGMA optimize'); } catch { /* ignore */ }
for (const p of analyze) { try { this.db.exec(p); } catch { /* ignore */ } }
try { this.db.exec('PRAGMA wal_checkpoint(PASSIVE)'); } catch { /* ignore */ }
return;
}
await this.runPragmasOffThread(
['PRAGMA analysis_limit=1000', 'PRAGMA optimize', 'PRAGMA wal_checkpoint(PASSIVE)'],
[...analyze, 'PRAGMA wal_checkpoint(PASSIVE)'],
// Worker threads unavailable — bounded in-line fallback, no checkpoint.
['PRAGMA analysis_limit=1000', 'PRAGMA optimize']
analyze
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ export class CodeGraph {
// maintenance checkpoint don't contend for the checkpointer lock
// (the loser would silently no-op and leave the WAL unfolded).
if (walValve) { walValve.stop(); await walValve.drain(); }
await this.db.runMaintenance();
await this.db.runMaintenance({ fullAnalyze: true });
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] maintenance: ${Date.now() - tMaint}ms`);
}

Expand Down