Skip to content

Feat/ingest memory instrumentation - #54

Merged
xerj-org merged 3 commits into
xerj-org:mainfrom
probelabs:feat/ingest-memory-instrumentation
Jul 27, 2026
Merged

Feat/ingest memory instrumentation#54
xerj-org merged 3 commits into
xerj-org:mainfrom
probelabs:feat/ingest-memory-instrumentation

Conversation

@buger

@buger buger commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds opt-in ingest-memory attribution and a bounded, deterministic shipped-path diagnostic suite.

It does not claim to fix the unbounded-ingest heap problem by itself. It makes that problem measurable: a developer can now separate live logical ownership from allocator retention, RSS, file-backed memory, and still-uninstrumented memory, then reproduce lifecycle regressions in seconds or minutes instead of repeatedly indexing the full FinanceBench corpus.

The default runtime remains unchanged. Instrumentation is disabled unless XERJ_INGEST_MEMORY_TRACE=summary is set.

Why this is useful

Our large-PDF and FinanceBench investigations showed process RSS growing far beyond the raw corpus, but the existing RSS and memtable counters could not answer which phase still owned the bytes. Without owner-level attribution, a memory optimization can appear successful while merely moving retention from one subsystem to another.

This PR provides two independent views:

  • xerj.ingest_memory.v1: logical ingest owners plus jemalloc allocated/active/resident, process RSS, process CPU time, accounting errors, and dropped output.
  • xerj.process_sample.v1: an external Linux /proc view of RSS/HWM, anonymous/file-backed RSS, PSS, private pages, swap, I/O, CPU ticks, threads, and file descriptors.

Those streams are intentionally separate so one instrument can expose disagreement or observer error in the other.

What is instrumented

The engine records lifecycle ownership for:

  • ES-compatible HTTP request bodies.
  • Ignore-malformed rewrite buffers.
  • Shared raw semantic source bytes.
  • Parsed semantic documents.
  • Caller-supplied vector capacity.
  • Prepared documents.
  • Derived and prepared vector capacity.
  • Active memtables.
  • Drained flush snapshots through finalization and publication.

Merge and read-cache category names are present but explicitly report unavailable. They are not reported as zero, because those owners are not wired yet.

Semantic ownership follows the real asynchronous lifecycle. Raw shared ownership outlives every Arc clone, document/vector transitions are emitted atomically, prepared ownership ends after each actual publication, and drained ownership remains charged through the blocking finalizer and publisher. Success, partial failure, cancellation-style cleanup, publication failure, malformed input, and flush failure paths are covered.

Runtime behavior and safety

Enable summary tracing with:

XERJ_INGEST_MEMORY_TRACE=summary \
XERJ_INGEST_MEMORY_SAMPLE_MS=100 \
XERJ_INGEST_MEMORY_OUTPUT=/tmp/xerj-ingest-memory.ndjson \
xerj --insecure --data-dir /tmp/xerj-data

XERJ_INGEST_MEMORY_SAMPLE_MS is clamped to 25–60000 ms. The output can be a file path or tracing. Newly created files are mode 0600.

The sink is non-blocking and bounded to 256 queued lines. Output failures increment dropped-event accounting and stop the failed writer instead of blocking ingest. Shutdown synchronously refreshes memtable, allocator, RSS, and CPU state before writing trace_stop, so the final record does not rely on a stale periodic sample.

With tracing disabled, structural tests prove that the semantic path does not perform instrumentation schema reads, JSON sizing walks, owner-map allocation, or guard-vector construction.

Bounded diagnostic suite

The new suite lives at demo/usecases/autoindex/scale/bounded/.

It generates a deterministic typed corpus, creates a real semantic_text mapping, and drives XERJ through the ES-compatible HTTP path:

  1. Bulk ingest.
  2. Refresh and exact count/sentinel verification.
  3. Explicit flush and verification.
  4. Force merge and verification.
  5. Graceful shutdown.
  6. Restart from the same durable data.
  7. Exact count/sentinel verification after restart.

The default --embed-mode lexical exercises XERJ's deterministic feature-hash embedding lifecycle. It does not claim neural semantics.

Example:

cd engine
CARGO_PROFILE_RELEASE_LTO=off cargo build --release -j 32 -p xerj-server

cd ..
SUITE=demo/usecases/autoindex/scale/bounded
python3 "$SUITE/generate_corpus.py" --output /tmp/xerj-bounded-corpus --documents 4096
python3 "$SUITE/run_suite.py" \
  --binary engine/target/release/xerj \
  --corpus /tmp/xerj-bounded-corpus \
  --output /tmp/xerj-bounded-run \
  --profile nightly
python3 "$SUITE/analyze.py" /tmp/xerj-bounded-run

The runner refuses to overwrite an output directory. Every cell uses a fresh server process and data directory. Failed phases clean up the server without masking the original exception. Individual HTTP operations time out after five minutes.

The run manifest records the binary hash and size, binary version, suite checkout and dirty state, corpus hash, host, configuration, correctness results, and every raw artifact's size and SHA-256. Raw and derived artifacts are mode 0600.

The analyzer rejects:

  • Wrong or malformed telemetry schema.
  • Missing, reordered, or non-contiguous trace events.
  • Category or measurement-vocabulary drift.
  • Accounting errors or dropped output.
  • Nonzero measured logical ownership at trace_stop.
  • Missing physical measurements that claim to be available.
  • Missing or changed artifacts.
  • Missing or reordered refresh/flush/merge/restart checks.
  • Wrong counts or altered sentinel IDs/results.
  • A semantic run that never records prepared-vector ownership.

It reports per-cell peaks, RSS per document, and incremental RSS slopes without inventing an absolute host-independent threshold. Optional versioned budgets are supported, but their headroom multiplier must be chosen explicitly from repeated runs on a pinned host.

The README also includes ready-made prompts for an AI reviewer to validate one run, compare two runs, and diagnose the first phase where RSS separates from allocator and logical ownership.

Verified results

Deterministic tests

  • Engine library suite: 191 passed, 0 failed.
  • Focused bounded engine suite: 4 passed, 0 failed.
  • Python harness suite: 8 passed, 0 failed.
  • Server trace tests cover mode parsing, disabled-mode behavior, bounded sink loss, private output, existing-file permissions, final shutdown refresh, and interruption.

The engine regression tests cover exact JSON/vector capacity formulas, real semantic bulk ownership, caller vectors, malformed input, injected embedding and publication failures, repeated same-ID overwrites, three flush cycles, cache warming, force merge, zero retained owners, and zero accounting errors.

Live shipped-path smoke

A release binary was exercised with real semantic_text HTTP ingestion at 32 and 64 documents.

  • Every bulk write succeeded.
  • Exact counts and three exact sentinel IDs passed after refresh, explicit flush, force merge, and restart.
  • Both telemetry streams passed schema, sequence, hash, balance, dropped-event, and accounting validation.
  • Prepared-vector ownership was nonzero during ingest and returned to zero.
  • Prepared-vector peak at 64 documents: 27,648 bytes.
  • No server child remained after the run.
  • Derived analysis.json and report.md were mode 0600.

This small smoke is a harness and lifecycle proof, not a throughput benchmark.

Broader instrumentation smoke

A separate release smoke ingested 2,600 ordinary, caller-vector, and semantic documents. All writes succeeded, restart counts and representative vectors matched, and an instrumentation-off control created no trace output.

An immediate-SIGINT smoke ingested 2,000 documents with no explicit flush and a 60-second sample period. The synchronous final record reported every logical current at zero, fresh physical probes, zero accounting errors, and zero dropped events.

Compatibility and build gates

  • cargo fmt --all --check: passed.
  • cargo check -p xerj-engine --tests: passed.
  • Python compilation and git diff --check: passed.
  • Full ES-YAML hard gate: 1360 passed, 0 failed, 3 skipped.

Overhead and limitations

  • Default-off mode is designed to avoid instrumentation work, but this PR does not publish a microbenchmark claiming a precise zero-percent runtime cost.
  • Summary mode adds sizing, bookkeeping, periodic physical probes, and bounded NDJSON output. It is diagnostic mode, not the benchmark default.
  • Logical byte counts are ownership estimates and exact collection capacities where identified; they are not allocator identities and must not be summed with jemalloc or RSS.
  • Active memtable and drained-flush observations can transiently overlap across a sampled handoff. The trace labels this explicitly.
  • Merge and cache owners remain unavailable.
  • This PR does not add a memory governor, repair the heap defect, establish an 8 GiB bound, or prove the 368-PDF FinanceBench target.
  • The bounded suite reduces iteration time, but accepted performance claims still require the real FinanceBench and release-profile comparisons.

Relationship to the profiling-tooling PR

This PR is independent of feat/debug-profiling-tooling. It does not use pprof, jemalloc_pprof, or the profiling build profiles. Either PR can merge first.

The tools are complementary:

  • This PR answers which logical ingest phase owns memory and when.
  • The profiling PR answers which stack allocated CPU or heap samples.

Follow-up enabled by this PR

The immediate follow-up is to run the bounded suite across increasing cells, capture heap/CPU profiles at the first divergence, wire merge/cache owners, and then implement the smallest evidence-backed memory-bound fix. Each fix can be proven on the deterministic suite before paying the cost of the full 368-PDF FinanceBench run.

buger added 2 commits July 27, 2026 18:44
Sustained bulk ingest can grow process RSS far beyond the raw corpus, but RSS
and aggregate memtable counters cannot identify which lifecycle still owns the
bytes. Policy changes made without that attribution risk moving memory rather
than bounding it.

Add an opt-in `xerj.ingest_memory.v1` ownership ledger and sampler. The default
mode is fully off. `XERJ_INGEST_MEMORY_TRACE=summary` enables bounded snapshots
containing logical lifecycle gauges, jemalloc allocated/active/resident, RSS,
process CPU time, accounting underflows, and dropped-output events.

Wire real owners for HTTP bodies and rewritten buffers, shared raw semantic
sources, parsed documents and caller vectors, prepared documents and derived
vectors, observed active memtables, and drained flush snapshots. Semantic
document/vector transitions are atomic, raw Arc ownership outlives every clone,
prepared ownership ends after each real publication, and flush ownership spans
the shard drain through the blocking finalizer/publisher.

Keep merge and cache names reserved and explicitly unavailable rather than
reporting false zero measurements. Document that sampled MemtableActive can
briefly overlap authoritative FlushDrained and that logical totals are
diagnostic attribution, not allocator identities.

The summary sink is non-blocking and bounded to 256 lines. Newly created
NDJSON files are mode 0600. Output failures increment dropped-event accounting
and stop the failed writer. Shutdown synchronously refreshes memtable,
jemalloc, RSS, and CPU state before trace_stop, so a completed final flush is
not represented by a stale sampled memtable value.

Disabled-mode structural tests prove no semantic schema reads, sizing walks,
owner maps, or guard vectors. The lifecycle harness exercises real embedding
and publication barriers, mixed per-item embedding and publication failures,
caller and custom vectors, malformed input, multiple targets, index-creation
failure, panic cleanup, sequential prepared-memory decline, and target/task
isolation. Concurrent success and failure flushes prove distinct task-local
ledgers remain live through their publishers and refund to zero.

Verification:

- Default-parallel engine library suite: 187 passed.
- Server suite: 11 passed before sink hardening; seven focused trace tests pass
  afterward, including 60-second shutdown interruption, mode-0600 output,
  preserved existing permissions, /dev/full loss accounting, and final refresh.
- Formatting, diff checks, and scoped engine/server checks pass.
- Immediate-SIGINT release smoke: 2,000 documents, no explicit flush, 60-second
  sample period; trace_stop reports every logical current as zero with refreshed
  physical probes, zero accounting errors, and zero dropped events.
- Broader live smoke: 2,600 ordinary, caller-vector, and semantic documents;
  all writes succeeded, restart counts and representative vectors matched, and
  the off control created no trace output.
- ES-YAML hard gate on final server
  e759e9e09dd168d43f4cad76a611beaf2a96977ac02f26e96b84fdccd064e889:
  1360 passed, 0 failed, 3 skipped.

This commit adds diagnostics, not a heap bound, cache budget, merge rewrite, or
performance claim. It makes those follow-up changes measurable.
Full FinanceBench runs are too slow and noisy to use as the first detector for
ingest ownership leaks, allocator retention, durability regressions, or restart
corruption. RSS alone also cannot distinguish live logical owners from jemalloc
retention, file-backed mappings, or uninstrumented memory.

Add a deterministic two-layer diagnostic suite. Engine tests exercise exact JSON
and vector sizing, real semantic bulk ownership, malformed input, injected
embedding/publication failures, repeated same-ID overwrites, flushes, cache
warming, and force-merge churn. All engine seams are cfg(test)-only.

Add a Linux shipped-path runner that creates a typed semantic_text index and
drives real HTTP bulk, refresh, explicit flush, force merge, graceful shutdown,
restart, exact counts, and three exact sentinel lookups. The default lexical
feature-hash embedder exercises the embedding lifecycle without claiming neural
semantics. Each cell uses a fresh process and data directory.

Capture independent internal xerj.ingest_memory.v1 and external /proc telemetry.
Bind successful runs to binary, corpus, checkout, configuration, host, artifact
sizes, and SHA-256 hashes. The analyzer rejects schema/category/measurement
drift, missing or reordered trace events, accounting errors, dropped output,
stop-time retained owners, missing RSS, artifact changes, missing durability
phases, and count/sentinel corruption. It reports relational RSS slopes without
inventing a host-independent threshold; optional budgets require an explicit
headroom multiplier.

Failed phases clean up server processes without masking the original exception.
Sampling intervals must be positive, generated TOML paths are escaped, output
directories are never overwritten, and raw plus derived artifacts are private.
The runner caps individual HTTP operations at five minutes.

Verification:

- Engine library suite: 191 passed, 0 failed.
- Focused bounded engine suite: 4 passed, 0 failed.
- Python harness suite: 8 passed, 0 failed.
- cargo check -p xerj-engine --tests passed.
- cargo fmt --all --check, Python compilation, and git diff --check passed.
- Live semantic_text smoke at 32 and 64 documents passed every refresh, flush,
  merge, shutdown, and restart boundary. Prepared-vector ownership peaked at
  27,648 bytes and returned to zero.
- Independent hostile-review smoke passed at 32 and 64 documents.
- ES-YAML hard gate: 1360 passed, 0 failed, 3 skipped.

This suite is a fast diagnostic and regression instrument. It is not a
FinanceBench result, a production memory bound, or a performance claim. Cache
and merge attribution remains explicitly unavailable until those owners are
wired.
CI runs cargo clippy --workspace --all-targets with warnings denied. The instrumentation branch introduced four findings: the flush helper's argument allowance was attached to the test-hook type instead of the helper, Ledger exposed new() without Default, and two sink receive loops used loop-plus-match forms.

Attach the narrowly documented argument-count exception to do_flush_shard itself, implement Default for Ledger through its const constructor, and express both blocking receiver loops as while-let loops without changing shutdown or sink-error behavior.

Verified after rebasing onto origin/main 64a83a9: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; full xerj-engine tests; seven ingest-memory server tests; and eight bounded diagnostic Python tests.
@buger
buger force-pushed the feat/ingest-memory-instrumentation branch from 93a0f1d to e29aa85 Compare July 27, 2026 16:51
@xerj-org
xerj-org merged commit c202022 into xerj-org:main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants