Skip to content

Commit 3d71685

Browse files
authored
perf(scan): dual-path walker — parallel bulk, serial early-exit (#821)
## Summary Adds a parallel async-`readdir` walker alongside the pre-existing serial walker, selected per-walk by a new `concurrency` option. Bulk scans (DSN detection, sourcemap discovery, uncapped grep) speed up by 22-37%; early-exit paths (DSN `scanCodeForFirstDsn`) are unchanged. ## Why Profiling against ripgrep on a 10k-file synthetic fixture revealed our walker was 5× slower than a raw sync walk. The dominant cost: serial async `readdir` (109ms for 3636 dirs vs 44ms sync) because each call hops through a microtask. Overlapping `readdir` across directories recovers the loss — 4 workers hit 28ms for the same traversal. But we can't just make everything parallel. Two early-exit paths (`scanCodeForFirstDsn`, `detectDsn.cold`) need to stop on the first hit. The parallel walker's producer-consumer channel adds ~7 ms per emitted file — fine for bulk scans, fatal for "find one and bail" consumers where the serial walker is 2ms total. Benchmarking showed those paths regressed 30-50× under a naïve uniform-parallel walker. The fix is a dual-path dispatch based on `concurrency`: 1 → serial, > 1 → parallel. The default is `bulkConcurrency()` (= `max(2, availableParallelism())`). Early-exit callers pass `concurrency: 1` explicitly. ## Design `walkFiles` dispatches to one of two internal generators that share per-entry helpers (`processEntry`, `maybeDescend`, `tryYieldFile`, `classifyFile`): - **`walkSerial`** — byte-for-byte the pre-refactor body: LIFO stack, direct `yield`, sync `readdirSync`. Per-dir overhead ~8 µs. Optimal for early-exit. - **`walkParallel`** — N workers pull from the shared stack. Idle workers park on a `workerAwake` Promise that uses **swap-then-resolve** (new Promise installed before resolving the old) so late awaiters can't latch onto a stale resolution. `signalWorkers()` fires on every descent (`pushFrame`), last-active-worker-exit-with-empty-stack, and cancellation. File entries flow through a `pending` buffer drained by the generator. A worker-side outer `try/catch` stores errors (including `AbortError` from pre-fired signals) into `producerError` for the consumer to re-throw. This fixes a hang where a pre-fired `AbortSignal` would park the consumer on `consumerAwake` forever because `activeWorkers` was never incremented. ## Caller updates | Caller | Path | Concurrency | |---|---|---| | `scanCodeForDsns` | bulk scan | default (parallel) | | `scanCodeForFirstDsn` | early-exit | explicit `1` (serial) | | `collectGrep` (init wizard, DSN scan) | bulk | default (parallel) | | `discoverFilePairs` (sourcemap inject) | bulk | default (parallel) | ## Tests 5 new parallel-walker tests in `test/lib/scan/walker.test.ts`: - `yields the same set of files as the serial walker` — correctness equivalence - `honors gitignore rules across concurrent descents` — nested `.gitignore` loads atomically per-dir - `honors descentHook under concurrent traversal` — monorepo depth-reset works with workers - `aborts mid-walk when signal fires` — mid-walk abort propagates - **`propagates a pre-fired abort through the parallel walker`** — regression for the Blocker fix All 36 walker tests + 232 scan tests + 5703 full-suite tests + 138 isolated tests pass. ## Performance (synthetic/large, 10k files, linux/x64, Bun 1.3.11) | op | before | after | Δ | |---|---:|---:|---:| | `scanCodeForDsns` | 238ms | 181ms | **−24%** | | `detectAllDsns.cold` | 253ms | 198ms | **−22%** | | `scan.walk.noExt` | 451ms | 286ms | **−37%** | | `scan.walk.dsnParity` | 146ms | 132ms | −10% | | `scan.grepFiles` | 169ms | 163ms | −4% | | `detectDsn.cold` | 5.57ms | 4.63ms | −17% | | `scanCodeForFirstDsn` | 2.28ms | 1.92ms | −16% | All other ops within ±10% of baseline. ## vs ripgrep rg is still faster on bulk patterns (it uses 4 native threads with work-stealing), but the gap narrowed considerably. Our uncapped `NONEXISTENT_TOKEN_XYZ` went from 6.9× rg to ~4.5×. For capped grep with `maxResults: 100` (init wizard's default) we're actually faster than rg on dense patterns due to early-exit. This is probably the last major architectural win available without native code. ## Review cycle Two rounds of subagent review before opening the PR: - **Round 1** (ses_249398303ffe0wYvzoF5RhiSBh): 4 Important findings + 4 Minor. All addressed. - **Round 2** (ses_2491fea0cffeYtcfVYQRtKX9uk): 1 Blocker (pre-fired abort hang), 5 Minor. All fixed; pre-fired abort now has a dedicated regression test + negative verification. ## Test plan - [x] `bunx tsc --noEmit` — clean - [x] `bun run lint` — clean (1 pre-existing markdown.ts warning) - [x] `bun test test/lib test/commands test/types` — 5703 pass, 0 fail - [x] `bun test test/isolated` — 138 pass - [x] `bun run bench --size large` — all ops equal-or-better - [x] Negative-verified pre-fired abort test catches the bug it guards against
1 parent 38640a9 commit 3d71685

5 files changed

Lines changed: 619 additions & 56 deletions

File tree

src/lib/dsn/code-scanner.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ export function scanCodeForFirstDsn(cwd: string): Promise<DetectedDsn | null> {
156156
for await (const entry of walkFiles({
157157
cwd,
158158
...dsnScanOptions(),
159+
// Early-exit consumer: we `return` on the first DSN-bearing
160+
// file. The parallel walker's channel adds ~7ms per-file
161+
// overhead; the serial walker uses a direct `yield` so
162+
// `break` cuts immediately. Measured 2ms (serial) vs ~75ms
163+
// (parallel) on the large bench fixture.
164+
concurrency: 1,
159165
})) {
160166
filesScanned += 1;
161167
let content: string;

src/lib/scan/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ export type {
7070
WalkEntry,
7171
WalkOptions,
7272
} from "./types.js";
73-
export { walkFiles } from "./walker.js";
73+
export { bulkConcurrency, walkFiles } from "./walker.js";

src/lib/scan/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,25 @@ export type WalkOptions = {
137137
*/
138138
signal?: AbortSignal;
139139

140+
// --- Parallelism ---
141+
/**
142+
* Max number of directories to `readdir` in parallel. Default:
143+
* `bulkConcurrency()` (= `max(2, availableParallelism())`), chosen
144+
* to overlap async `readdir` I/O across directories — the primary
145+
* speedup (~25%) for bulk scans like DSN detection or sourcemap
146+
* discovery.
147+
*
148+
* Set to `1` for **early-exit** traversal, which uses a direct
149+
* `yield` serial path (no producer-consumer channel) and sync
150+
* `readdirSync` — measurably faster when the consumer `break`s
151+
* after the first few files (e.g. `scanCodeForFirstDsn`).
152+
*
153+
* This only controls directory-level concurrency. File content
154+
* reads (done by callers like `grepFiles`) have their own
155+
* concurrency knob.
156+
*/
157+
concurrency?: number;
158+
140159
// --- Time budget ---
141160
/**
142161
* Wall-clock budget in milliseconds. Every directory at depth

0 commit comments

Comments
 (0)