Deep-scan pipeline
Reference + design for deep-scan — the sweep that knowledge-scans a workspace's entire transitive dependency closure into the warehouse, so the viz 🕸 Call Graph and the knowledge_* MCP tools answer over every crate the workspace pulls in (deps of deps, the whole tree), not just the workspace members.
Scope — this is opt-in and separate from the member scan. deep-scan runs only for a workspace with deep_scan = true in its descriptor (e.g. holger). It is the heavy path: holger's closure is ~1749 crates / ~50k files, scanned in ≈9s (was ≈33s before this design). A workspace without deep_scan (the default) republishes only its own member repos' knowledge — unchanged members ⇒ ≈0.1–1s, a fresh full member scan ⇒ proportional to the members' own source (not their deps). The member scan (scan_repo) is a distinct path from this dependency-closure path; it is no longer serial — its syn parse fans across all cores (parse_files_parallel, a heaviest-first/LPT, batched, no-barrier worker pool — the in-repo mirror of zoomies gatling_for_each_balanced; ≈3.3× under load, identical output). deep-scan below is the dependency-closure path.
Source code: src/deepscan.rs (the two-stage gatling pipeline — the authority), src/knowledge/symbols.rs (scan_file / scan_content, the per-file parse unit), and src/warehouse/iceberg.rs (ingest_symbol_scans_async + build_groups_parallel, the all-core flush into skade).
The whole pipeline is pure Rust, zero C FFI and runs under the mimalloc global allocator (per-thread heaps — set in the server/CLI bins so 32-worker columnization doesn't serialize on the glibc arena lock).
1. What it is
deep_scan(member_dirs, wh) does four things, in order:
resolve closure → Stage 1 DECOMPRESS → flat Vec<CrateFile> → Stage 2 PARSE → parallel FLUSH → warehouse
Cargo.lock 32 gatling workers crate · tar_path 32 gatling workers build_groups_parallel symbol_facts
in-process, lgz, per-crate · content (.rs) per-FILE syn parse → skade::ingest_parallel call_edges
no shell (~0s) (all cores build+encode) feature_gate_facts
Rows land under repo = "deps" (DEPS_REPO), tagged with one snapshot id for the whole run, so the closure shows up as a single repo deps with one crate entry per dependency.
2. The two-stage shape — parse is FILE-granular
Deep-scan splits decompress from parse and makes parse fan out one file at a time:
- Stage 1 (decompress) is crate-granular — a cargo
.crateis a single gzip stream, so one crate decompresses on one worker. The huge single-streamwindows-*gzips therefore decompress on separate workers concurrently. - Stage 2 (parse) is one file per work unit. A monster crate's thousands of generated
.rsfiles spread across all 32 cores, so there is no serial tail — every core stays busy until the last file is parsed. (A crate-granular parse would pin one core per crate and let thewindows-*giants stall the sweep while the rest idle.)
That is why the pipeline materializes a flat Vec<CrateFile> between the stages: it is the unit boundary that lets parse fan out file-by-file regardless of which crate a file came from. scan_file rows self-tag with crate_name, so the flat list needs no per-crate reassembly downstream.
3. The gatling no-barrier engine
Both stages run on znippy's gatling engine (znippy_zoomies::gatling::run_typed), the no-barrier worker-pool streamer. The shape is always 1 reader → N workers → 1 sink:
- the reader streams the input as newline-delimited text in small chunks (
chunk_size: 8 KiB,ring_slots: 8,SlotFill::Incremental) — there is no whole-input single-chunk barrier; - a shared
line_splitcodec cuts one segment per complete line (a partial trailing line becomes carry and waits for the next chunk); - each worker runs
transformon one segment, entirely independent of its siblings; - the sink sees outputs in stream order on the single collector thread (single writer, no lock war).
No barrier means a worker grabs the next segment the instant it finishes its current one — it never waits for siblings to reach a checkpoint. A fast file and a slow file simply pull different numbers of units; no core idles waiting on a phase boundary.
workers = std::thread::available_parallelism() (32 on the box). No rayon anywhere in the scan path — gatling owns the parallelism.
Stage 1 — DECOMPRESS (DecompressCodec + CollectSink)
input : one "label\tdir\n" line per crate
split : line_split → one segment per crate line
transform(seg): # decompress_crate(label, dir)
tarball = registry/src/.../<name-ver>/ → registry/cache/.../<name-ver>.crate
if tarball exists:
bytes = read(tarball) # the .crate gzip
entries = lgz::decompress_tar_gz_filter(bytes, ".rs") # zero-copy, ".rs" only
return [ CrateFile{ crate_name=label, tar_path=path, content } for (path, bytes) in entries ]
else: # path/git dep, no .crate
walk <dir>/{src,tests,benches,examples} for *.rs
return [ CrateFile{ label, tar_path="<label>/<sub>/<rel>", content } ... ]
sink : CollectSink — files.extend(out) # flat Vec<CrateFile> across ALL crates
lgz is the home-made parallel zero-copy gunzip. A cargo .crate is a single gzip stream, so lgz falls back to single-threaded per crate — but it is fast (≈824 MB/s; the 123 MB windows crate decompresses in ≈0.15s), and crates run on different workers concurrently.
Stage 2 — PARSE (ParseCodec<'a> + WarehouseSink)
input : one file INDEX per line — "0\n1\n2\n…" for 0..files.len()
codec : ParseCodec holds &files, the run snapshot id, the run ts
split : line_split → one segment per index
transform(seg):
idx = parse(seg) as usize
f = files[idx] # ONE file
return symbols::scan_file(f.crate_name, f.tar_path, f.content, "deps", snap, ts)
# scan_file: strip "<name-ver>/" → rel; section must be src|tests|benches|examples;
# syn-parse content → SymbolScan{ symbols, calls, features }
sink : WarehouseSink.process(Ok(scan)):
push scan into `pending` IF it has any symbols|calls|features (skip empties)
WarehouseSink.process(Err(e)):
report.errors.push(e) # one bad file never stops the sweep
# NO flush here — deep_scan flushes explicitly so parse and flush time separately
scan_file is the per-file parse unit; it returns a SymbolScan with three row vectors:
| field | row type | warehouse table |
symbols |
SymbolRow |
symbol_facts |
calls |
CallEdgeRow |
call_edges |
features |
FeatureGateRow |
feature_gate_facts |
4. The all-core flush
WarehouseSink::flush() calls IcebergWarehouse::ingest_symbol_scans (sync wrapper over ingest_symbol_scans_async). The parse workers never block on a commit — they only buffer SymbolScans into pending; the flush runs once per crate-batch (and once at the end) and is itself all-core, in two parts:
ingest_symbol_scans_async(scans) -> IngestStats: # scans = the buffered per-FILE SymbolScans
n_groups = available_parallelism() # ~N_CORES
chunk = ceil(scans.len() / n_groups) # balanced bucket size
tables = []
for each table in [symbol_facts, call_edges, feature_gate_facts, test_inventory]:
# (1) COLUMNIZE IN PARALLEL — build_groups_parallel:
# std::thread::scope, N workers, each grabs the next bucket index
# from a shared AtomicUsize (gatling-style, no rayon) and columnizes
# its bucket's rows into ONE Arrow RecordBatch. Returns batches in
# bucket order. This is the step that used to be the single-core floor.
groups = build_groups_parallel(scans, chunk, n_groups, |bucket| columnize(bucket))
tables.push((table, groups)) # gather — DON'T commit yet
# (2) ENCODE + ONE CROSS-TABLE COMMIT — skade encodes every table's parquet
# across ALL cores, stages each table's fast_append, and flips ALL of them
# in a SINGLE redb transaction = ONE fsync (was 4 separate commits).
stats = skade::ingest_multi(catalog, tables, WriteProps::new(warehouse zstd))
# stats.commits == 1 for a non-empty flush.
So per flush there is one parquet file per bucket per table, and the whole four-table flush is one commit / one fsync (not one commit per table) — via skade's cross-table ingest_multi (the multi-table populate fast lane that stages every table's fast_append and flips them in one all-or-nothing redb txn). The data files skade writes are byte-identical to the old per-table ingest_parallel_with path's; only the commit collapses 4 → 1. Two distinct all-core stages: build_groups_parallel columnizes (the String→Arrow copy spread over every core), then skade::ingest_multi encodes on every core + does the single staged commit.
fsync law (RED-when-broken):
deepscan_multi_table_flush_is_one_fsync_and_lands_every_table(inwarehouse::icebergtests) ingests one scan that populates all four tables and assertsIngestStats.commits == 1and exactly one row landed per table — reverting to the per-table commit loop makescommits == 4and the test goes red.
Both halves allocate heavily (millions of small Arrow strings), so the mimalloc global allocator is what lets them actually scale — under the stock glibc allocator the 32 columnize workers serialize on the arena lock (~2× instead of ~32×). Measured on holger's closure: arrow-build ≈1.2s | skade-ingest ≈1s (the flush was ≈9s single-core before the parallel build + mimalloc).
A/B probe: NORNIR_DEEPSCAN_NOSAVE=1 skips the write entirely to isolate parse cost from flush cost.
5. The driver loop (deep_scan)
deep_scan(member_dirs, wh):
closure = resolve_dep_closure(member_dirs) # in-process Cargo.lock, no shell; ~1749 crates ~0.0s
present = [ (label, dir) for (label, dir) in closure if dir.is_dir() ]
skipped = closure.len() - present.len() # .crate not on disk / git dep
workers = available_parallelism() # 32
snap = new uuid # ONE snapshot id for the whole run
ts = now
for batch in present.chunks(BATCH): # BATCH = 1_000_000 → effectively one batch
# Stage 1
crate_input = "".join(f"{label}\t{dir}\n" for (label, dir) in batch)
collect = CollectSink{}
gatling::run_typed(crate_input, DecompressCodec, collect, workers, scan_cfg())
files = collect.files # flat Vec<CrateFile>
if files empty: continue
# Stage 2
idx_input = "".join(f"{i}\n" for i in 0..files.len())
gatling::run_typed(idx_input, ParseCodec{files, snap, ts}, sink, workers, scan_cfg())
# FLUSH this batch
sink.flush()
sink.flush() # final
report.crates = present.len()
return report
One batch is intentional: the box has ~500 GB RAM and a closure's decompressed source is only a few GB, so decompressing all crates together is what lets the big windows-* gzips overlap on separate workers, and the whole scan does a single bulk commit. BATCH exists only to bound peak memory if that ever changes.
Timing is reported split — the driver prints ⏱ resolve_dep_closure … {N} crate(s) in {t}s and then ⏱ decompress {a}s | parse {b}s | flush {c}s | total {d}s ({files} files, {crates} crates) so each stage is measured independently.
6. Laws this pipeline upholds
- No shelling. The closure is resolved by reading each member's
Cargo.lockin-process — nocargo fetch/cargo metadatasubprocess. The pipeline is pure Rust end-to-end (lgz=flate2/miniz_oxide,syn, arrow/parquetUNCOMPRESSEDso no zstd C, redb); no C FFI on the hot path. - No rayon in the scan path. Both gatling stages and the parallel columnize (
build_groups_parallel,std::thread::scope) use plain worker pools; the onlyrayonis inside skade duringingest_parallel, behind the warehouse boundary. - Zero-copy / zero-allocations is the goal; mimalloc is the floor-lifter.
lgzdecompresses.crategzips zero-copy;syn::parse_fileborrows the source (zero-copy input); the flush hands Arrow to skade which recasts to parquet without re-buffering. Where allocation is unavoidable (columnizing rows into Arrow), the mimalloc global allocator keeps 32-worker allocation from serializing on the system allocator. - No-barrier gatling. A worker grabs the next unit the instant it finishes — it never waits for siblings. File-level parse means even a monster crate's files keep all 32 cores busy to the very end.
- Persist to the warehouse, not /tmp. Results land in the Iceberg tables (
symbol_facts,call_edges,feature_gate_facts); the viz and MCP read from there. (After deep-scan the caller republishes snapshots.)
7. Tests
src/deepscan.rs + src/warehouse/iceberg.rs carry the matrix entries that exercise the real pipeline (inject real input, assert real output):
resolve_dep_closure_reads_cargo_lock_in_process_excludes_path_members— a syntheticCargo.lock+ fakeCARGO_HOMEregistry: registry deps (asource) are included and mapped to their src dir; path/workspace members (nosource) are excluded. Proves the in-process, no-shell resolve.line_split_*— the shared splitter cuts one segment per complete line and carries a partial tail.decompress_transform_isolates_bad_lines— a malformed crate line yields no files, not a panic.decompress_then_scan_file_finds_symbols—decompress_crate(dir fallback) →scan_fileactually finds the injectedfn.deep_scan_two_stage_end_to_end_into_warehouse— two synthetic dep crates through the real two-stage gatling pipeline (decompress dir-fallback → parse files → ingest) into a temp warehouse, asserting both files decompress and the scan has no errors.build_groups_parallel_preserves_order_skips_empty_loses_nothing(iceberg.rs) — the parallel columnize keeps bucket order, skips empty buckets, and loses nothing across 1/4/8/32 workers.
Test scratch lands under /home/rickard/workX (the repo's .cargo/config.toml pins TMPDIR), never the T9 mount the checkout lives on.