Commit 3d71685
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
against1 parent 38640a9 commit 3d71685
5 files changed
Lines changed: 619 additions & 56 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
156 | 156 | | |
157 | 157 | | |
158 | 158 | | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
159 | 165 | | |
160 | 166 | | |
161 | 167 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
| 73 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
137 | 137 | | |
138 | 138 | | |
139 | 139 | | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
140 | 159 | | |
141 | 160 | | |
142 | 161 | | |
| |||
0 commit comments