Gunnar

nordisk/nornir

.nornir/deep-scan-pipeline.md

<!-- nornir:class: design -->
# 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`](../src/deepscan.rs) (the two-stage gatling
pipeline — **the authority**), [`src/knowledge/symbols.rs`](../src/knowledge/symbols.rs)
(`scan_file` / `scan_content`, the per-file parse unit), and
[`src/warehouse/iceberg.rs`](../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).

![deep-scan pipeline](assets/deep-scan-pipeline.svg)

## 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 `.crate` is a single gzip
  stream, so one crate decompresses on one worker. The huge single-stream
  `windows-*` gzips therefore decompress on *separate* workers **concurrently**.
- **Stage 2 (parse)** is **one file per work unit**. A monster crate's thousands
  of generated `.rs` files 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 the `windows-*` 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_split` codec cuts **one segment per complete line** (a partial
  trailing line becomes carry and waits for the next chunk);
- each **worker** runs `transform` on 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 `SymbolScan`s 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`
> (in `warehouse::iceberg` tests) ingests one scan that populates all four
> tables and asserts `IngestStats.commits == 1` **and** exactly one row landed
> per table — reverting to the per-table commit loop makes `commits == 4` and
> 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.lock`
  in-process — no `cargo fetch`/`cargo metadata` subprocess. The pipeline is pure
  Rust end-to-end (lgz=`flate2`/`miniz_oxide`, `syn`, arrow/parquet `UNCOMPRESSED`
  so 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 only
  `rayon` is *inside* skade during `ingest_parallel`, behind the warehouse boundary.
- **Zero-copy / zero-allocations is the goal; mimalloc is the floor-lifter.**
  `lgz` decompresses `.crate` gzips zero-copy; `syn::parse_file` borrows 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
  synthetic `Cargo.lock` + fake `CARGO_HOME` registry: registry deps (a `source`)
  are included and mapped to their src dir; path/workspace members (no `source`)
  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_file` actually finds the injected `fn`.
- `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.