Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/deep-scan-pipeline.md

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:

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:

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 (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

7. Tests

src/deepscan.rs + src/warehouse/iceberg.rs carry the matrix entries that exercise the real pipeline (inject real input, assert real output):

Test scratch lands under /home/rickard/workX (the repo's .cargo/config.toml pins TMPDIR), never the T9 mount the checkout lives on.