Warehouse WRITE bench — nornir.funnel_append (the un-benched leg)
✅ The batch win — landed (append_events / Store::persist_events)
The flagged fsync amortization is implemented: append_events(wh, &[Event]) writes N events in ONE Iceberg snapshot (one commit → one fsync), vs one snapshot per event. Store::persist_events is the persist-only bulk seam; store_subtasks now uses it — a plan-DAG (mother PlanCreated + N NodeAdded + M EdgeAdded) is now 1 snapshot instead of 2+N+M. Readback is byte-identical to per-event appends (proven: append_events_batch_is_byte_identical_to_single_appends; empty batch = no-op).
Two benchers: nornir.funnel_append (per-event baseline, N snapshots) and nornir.funnel_append_batch (same N events, 1 snapshot). Light 8-event smoke on a busy dev box (indicative only — real numbers come from the quiet bench box): per-event ≈ 42,571 µs/append (8 snapshots) → batch ≈ 1,766 µs/append (1 snapshot) — the per-append cost is snapshot-commit-bound, exactly as predicted; batching amortizes it. Keep-only-if-faster-AND-byte-identical: ✅ both hold. Heavy batched-vs-serial sweep on Loki/Odin still pending.
The warehouse read/scan paths were benched (dep_graph_build, knowledge_scan, delta_rescan, plan_dag_assemble) but the write leg was not. This adds it.
What it measures
nornir.funnel_append (src/bench_registry.rs) opens a temp IcebergWarehouse and appends N event-sourced funnel events (Event::IdeaSubmitted) via warehouse::funnel::append_event. Each append is its own Iceberg snapshot — that's the event-sourcing contract (funnel_events, every append time-travellable). Authoritative metric: events_per_sec. Latency/IO microbench (one warehouse, serial appends — not a parallel-compute arm; exempt from the core-saturation law like the other latency arms).
The path (per append)
Event → row-encode (1-row RecordBatch, per-append column builders) → ensure_table_schema (every call) → append_batch (TableIdent alloc) → **Iceberg snapshot commit (fsync)** → +1 snapshot.
Light number (this box, NOT the record)
8-event smoke (tests::funnel_append_bench_runs_light): ≈41 events/sec, ≈24.5 ms/append. The cost is dominated by the per-append snapshot commit (fsync), not the row-encode. Heavy sweep (thousands of appends, batched-vs-serial, honest cores_busy) runs on the quiet bench box (Loki/Odin) — never on a busy box (bench policy); bench data → /home.
The win (batching — prove it on the bench box)
The bottleneck is one snapshot per event. Batching many events into one append_batch (one snapshot, column builders sized once, one ensure_schema, one TableIdent) amortizes the ~24.5 ms fsync across the batch. Secondary allocation candidates on the hot path: the per-append RecordBatch builders and TableIdent::new(name.to_string()). Keep only if faster AND byte-identical readback (roundtrip-verify the events).
Wiring
Registered as nornir.funnel_append (64 events) via register_bench!; discovered by nornir_bench::api::registered_bench_ids. Light smoke test keeps it green in CI.
Introspection-warehouse WRITE bench — nornir.symbol_append (the second un-benched leg)
The funnel write leg (above) was benched; the introspection write leg — the one every nornir scan takes to persist symbols/calls/features/tests — was not. nornir.knowledge_scan measures the syn PARSE that produces the scan and nornir.warehouse_query measures the coverage READ back, but nothing measured the introspection persist until now. This adds it.
What it measures
nornir.symbol_append (src/bench_registry.rs) builds a synthetic SymbolScan — symbols symbols + symbols call-edges + symbols/10 feature-gates + symbols/10 tests — then times the real IcebergWarehouse::append_symbol_scan_async writing it into a temp-dir warehouse. Registered arm: 500 symbols (500 + 500 + 50 + 50 = 1100 rows). Authoritative metric: rows_per_sec. Latency/IO microbench (one warehouse, serial commits — not a parallel-compute arm; exempt from the core-saturation law like the other latency arms).
The path
Unlike funnel (one snapshot per event), the introspection append is already row-batched: append_symbol_scan_async builds ONE multi-row Arrow RecordBatch per non-empty table and does ONE append_batch (one snapshot) per table. So a full scan commits as four Iceberg snapshots (symbol_facts, call_edges, feature_gate_facts, test_inventory), regardless of row count. The per-row cost is dominated by the four snapshot commits (fsync), not the column encode.
Light number (this box, NOT the record)
40-symbol smoke (tests::symbol_append_bench_runs_light, 88 rows across 4 snapshots): ≈883 µs/row, ≈1133 rows/sec on a busy dev box (indicative only). Cost is the four snapshot commits, not the encode — same fsync-bound shape as funnel. Heavy sweep (scaling symbols 1k→100k, honest cores_busy) runs on the quiet bench box (Loki/Odin) — never on a busy box (bench policy); bench data → /home.
Candidate wins (prove on the bench box — keep only if faster AND byte-identical)
Already batched per-table, so the remaining levers are secondary and must be byte-identical-verified before keeping any of them:
- Zero-copy column build: each column is
StringArray::from(iter.map(|r| r.field.clone()).collect::<Vec<_>>())— aVec<String>alloc + a per-row.clone()per column.StringArray::from_iter_valuescopies each&str's bytes straight into the Arrow buffer (no intermediateVec<String>, no per-row clone), producing a byte-identical non-null array. Alloc reduction only — likely fsync-noise at these row counts; measure before landing. - Cross-table single commit: the four per-table snapshots are inherent to skade's per-table commit primitive; collapsing them is a skade-level change (out of nornir's scope), tracked against the skade warehouse worklist.
Wiring
Registered as nornir.symbol_append (500 symbols) via register_bench!; discovered by nornir_bench::api::registered_bench_ids. Light smoke test (symbol_append_bench_runs_light, 40 symbols) keeps it green in CI.