Skip to content

fix(protocol): defer freed-memory release until large computations go quiet - #394

Closed
jsam wants to merge 3 commits into
mainfrom
fm/il-384-recursive-reads
Closed

jsam wants to merge 3 commits into
mainfrom
fm/il-384-recursive-reads

Conversation

@jsam

@jsam jsam commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Fix GitHub issue #384 in inputlayer/inputlayer (production readiness, performance): "Recursive reads 9-20% slower since #304 (runaway-query guards): ?hot_reach(X,Y) 248 -> 301 ms at 100k edges".

Found by the #378 bisect on the benchmark host. Likely the per-step deadline/memory checks inside recursion. Make the check cheaper (check every N steps) without weakening the guard; add the case to the perf gate.

Deliver it as a small PR; done means merged with CI green.

What Changed

  • src/protocol/handler/supervise.rs: a computation whose memory peak exceeds RELEASE_AFTER_PEAK_BYTES no longer returns freed memory to the OS inline before dropping its permit. A new MemoryReleaser runs the release on the blocking pool once no large computation has finished for 1 s (RELEASE_QUIET), and at the latest 5 s after the first one pending (RELEASE_LATEST), so back-to-back large reads share one release instead of paying a purge and page re-fault each (about 50 ms per read, per the Recursive reads 9-20% slower since #304 (runaway-query guards): ?hot_reach(X,Y) 248 -> 301 ms at 100k edges #384 analysis recorded in the code comment). Query deadline and memory-limit checks are untouched; two paused-clock tests cover the quiet-period release and the five-second cap.
  • perf-gate: new gated recursive_query fixture — 25 serial ?hot_reach(X, Y) reads (3 warmup) over the deployed path / hot_reach rules on 100K edges — with recursive_query.latency_us.p50 added to the required set in policy.toml. The hot-chain graph loader (load_hot_chains, hot_head) moves out of keyed.rs into fixtures/mod.rs so both fixtures share it; the README documents the new fixture.
  • Docs: the deployment guide (and the regenerated gui/lib/docs-bundle.ts) now describes the deferred release, noting that freed memory can stay resident for those few seconds while the query and server memory limits apply as before.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The deferred, capped release is a small self-contained state machine whose traces (quiet release at last+1 s, cap at first+5 s, new window after each release) hold, the accounted memory limits are untouched, and the new gated fixture's expected row count and sample count agree with the dataset and policy; the remaining trade-offs (remedy differing from the intent's wording, freed pages resident for up to 5 s) were already decided by the user.

Testing

I built release servers for the base commit and this change and ran the new recursive_query gate fixture against both: p50 was 419.9 ms at base and 353.3 ms with the change (cost ratio 0.795, interval 0.723..0.885), and the gated metric passed. The host was busy (1-minute load 58 on 32 CPUs, baseline spread 52%), so the absolute numbers are noisy; the direction is clear from the paired rounds. The gate's overall verdict reads INVALID only because I ran one fixture of eleven; the full gate was not run here. With a small WebSocket driver against the real servers I then checked memory and guards: after a stream of reads the change returns memory about 1.0 s after the last read (base: after every read); under a stream that never goes quiet, about 1 GiB left by a big query went back 5.0 s after it finished; a query was refused with resource_exhausted at a 16 MiB limit and at the default 4 GiB limit, and a runaway recursion with deadline_exceeded at 2 s, with the server answering normally afterwards. The module's unit tests, including the two paused-time releaser tests, pass, but that is a unit-test result and not a live one, so the controlled-time releaser scenario is recorded as untested. No UI is involved, so there are no screenshots. Build outputs from this run were removed from the worktree and no server was left running.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
A client reads ?hot_reach(X, Y) over 100K edges repeatedly; the read is faster than at the base commit and returns the right 1,002 rows ✅ pass live scripts/perf-gate.sh --baseline-rev d5df1ba3 --fixtures recursive_query --rounds 6: p50 419.9 ms base vs 353.3 ms change, cost ratio 0.795 (interval 0.723..0.885); recursive-report.md
The perf gate measures and judges the new recursive_query case as a required metric ✅ pass live Gate report lists recursive_query.latency_us.p50 under Required metrics with status PASS and row counts checked on every timed reply; recursive-report.md
After a stream of large recursive reads stops, the server returns the freed memory to the operating system within about a second ✅ pass live rss-quiet.log: change holds 138 MiB until 1000 ms after the last read and is back at its 44 MiB idle level by 1250 ms (halfway point at 1012 ms)
During a stream of large reads the server no longer purges memory after every read ✅ pass live rss-quiet.log and rss-cap.log: lowest RSS during the stream 106 MiB on the change vs 53 MiB at base; sudden drops of 20 MiB or more in 14 s of reads: 5 on the change vs 39 at base
Adversarial: large reads never go quiet after a big query; the memory the big query freed is still returned at the five-second cap ✅ pass live rss-cap-big.log: 34 back-to-back reads with at most 363 ms between finishes; RSS stays at 1.1 GiB until 5.0 s after the big query, then falls to about 200 MiB
Adversarial: a query that outgrows its memory limit is still refused with resource_exhausted and the server keeps serving ✅ pass live guards.log: ?hot_reach(X, Y) refused after 69 ms at a 16 MiB limit, ?edge(0, Y) answers afterwards; rss-cap-big.log: a cross join refused at the default 4 GiB limit with RSS peaking at 4.2 GiB
Adversarial: recursion that never reaches a fixpoint is still stopped at the query deadline ✅ pass live guards.log: ?count(X) over count(Y) <- count(X), Y = X + 1 refused with deadline_exceeded after 2002 ms at query_timeout_ms = 2000; the next query answers
The releaser releases once after a quiet second and at the cap under a sustained stream (controlled time) ⏸️ untested no The prior payload did not establish a live result for this scenario: it was exercised only by cargo test --lib protocol::handler::supervise, a unit test under paused Tokio time, which is not the run…
Evidence: Gate result for recursive_query, base vs change
| metric | baseline | candidate | cost ratio | interval | budget | baseline spread | status |
| recursive_query.latency_us.p50 | 419.889 ms | 353.270 ms | 0.795 | 0.723..0.885 | 1.05 | 52.0% | PASS |

baseline d5df1ba3, candidate 8be9c5dc, profile standard, 6 rounds, busy host (1-minute load 58 on 32 CPUs)
Evidence: Gate report (full)
## Performance gate: INVALID

Policy **provisional**: cost budget p50 +5%, p99 +10%, throughput -5%; 95% interval of the median paired per-round ratio. Budgets are the plan's proposed ceilings, pending captain decision D1; tightening is always allowed.

- baseline: `d5df1ba3e25858c38a2f9c53a072c85e5d41e9d5` (binary sha256 70ce7b00824a)
- candidate: `8be9c5dc5cec2cab50bd26320dc7753684f26ea0` (binary sha256 b0a6ce172292)
- profile `standard`, 6 rounds, fixtures recursive_query
- host `computeinstance-e00w59r9a240k9qz1e`: AMD EPYC-Genoa Processor (32 CPUs, 125 GB), kernel 6.11.0-1016-nvidia, governor unknown, data on ext4 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M47C9D1HYNPRHQFEKNS2N22C/target/perf-gate/runs/20261006T062322Z/servers), server CPUs unpinned
- load average: start `57.78 50.43 40.71 92/1989 908974`, end `52.70 52.80 43.35 67/1931 1002036`
- **busy host**: 1-minute load 58 on 32 CPUs; results carry other work's noise
- build: rustc 1.99.0 (b940084d7 2026-09-28); cargo build --release --all-features

\### Problems

- bound_query.concurrent_latency_us.p99: baseline: 0 valid rounds, need 6
- bound_query.latency_us.p50: baseline: 0 valid rounds, need 6
- bound_query.latency_us.p99: baseline: 0 valid rounds, need 6
- bound_query.queries_per_sec: baseline: 0 valid rounds, need 6
- cheap_query.latency_us.p50: baseline: 0 valid rounds, need 6
- cheap_query.latency_us.p99: baseline: 0 valid rounds, need 6
- cheap_query.queries_per_sec: baseline: 0 valid rounds, need 6
- delta_fanout.delta_us.p50: baseline: 0 valid rounds, need 6
- delta_fanout.delta_us.p99: baseline: 0 valid rounds, need 6
- delta_fanout.last_agent_us.p99: baseline: 0 valid rounds, need 6
- delta_first.first_delta_us.p50: baseline: 0 valid rounds, need 6
- delta_first.warm_delta_us.p50: baseline: 0 valid rounds, need 6
- delta_keyed.ack_us.p99: baseline: 0 valid rounds, need 6
- delta_keyed.delta_us.p50: baseline: 0 valid rounds, need 6
- delta_keyed.delta_us.p99: baseline: 0 valid rounds, need 6
- delta_keyed.writes_per_server_cpu_sec: baseline: 0 valid rounds, need 6
- delta_single.delta_us.p50: baseline: 0 valid rounds, need 6
- delta_single.delta_us.p99: baseline: 0 valid rounds, need 6
- insert_batch.ack_us.p50: baseline: 0 valid rounds, need 6
- insert_batch.facts_per_sec: baseline: 0 valid rounds, need 6
- insert_single.ack_us.p50: baseline: 0 valid rounds, need 6
- insert_single.ack_us.p99: baseline: 0 valid rounds, need 6
- insert_single.concurrent_facts_per_sec: baseline: 0 valid rounds, need 6
- insert_single.facts_per_sec: baseline: 0 valid rounds, need 6
- interference.delta_us.p50: baseline: 0 valid rounds, need 6
- interference.delta_us.p99: baseline: 0 valid rounds, need 6

\### Required metrics

| metric | baseline | candidate | cost ratio | interval | budget | baseline spread | status |
|---|---|---|---|---|---|---|---|
| bound_query.concurrent_latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| bound_query.latency_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| bound_query.latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| bound_query.queries_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| cheap_query.latency_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| cheap_query.latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| cheap_query.queries_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_fanout.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_fanout.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_fanout.last_agent_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_first.first_delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_first.warm_delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.ack_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.writes_per_server_cpu_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_single.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_single.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_batch.ack_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_batch.facts_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.ack_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.ack_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.concurrent_facts_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.facts_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| interference.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| interference.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| recursive_query.latency_us.p50 | 419.889 ms | 353.270 ms | 0.795 | 0.723..0.885 | 1.05 | 52.0% | PASS |

\### Diagnostic metrics (not gated)

| metric | baseline | candidate | cost ratio | interval | budget | baseline spread | status |
|---|---|---|---|---|---|---|---|
| recursive_query.latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline round 1: 25 samples, need 100 |
Evidence: Gate run log (builds and measurement)
=== Build perf-gate ===
=== Build baseline server d5df1ba3e25858c38a2f9c53a072c85e5d41e9d5 ===
   Compiling proc-macro2 v1.0.107
   Compiling unicode-ident v1.0.26
   Compiling quote v1.0.47
   Compiling cfg-if v1.0.5
   Compiling libc v0.2.190
   Compiling serde_core v1.0.229
   Compiling serde v1.0.229
   Compiling version_check v0.9.5
   Compiling bytes v1.12.1
   Compiling once_cell v1.21.4
   Compiling autocfg v1.5.1
   Compiling libm v0.2.16
   Compiling zerocopy v0.8.60
   Compiling memchr v2.8.3
   Compiling equivalent v1.0.2
   Compiling itoa v1.0.18
   Compiling log v0.4.34
   Compiling find-msvc-tools v0.1.14
   Compiling shlex v2.0.1
   Compiling pin-project-lite v0.2.17
   Compiling parking_lot_core v0.9.12
   Compiling getrandom v0.3.4
   Compiling scopeguard v1.2.0
   Compiling bitflags v2.13.2
   Compiling pkg-config v0.3.34
   Compiling futures-core v0.3.34
   Compiling stable_deref_trait v1.2.1
   Compiling hashbrown v0.17.1
   Compiling futures-sink v0.3.34
   Compiling allocator-api2 v0.2.21
   Compiling foldhash v0.1.5
   Compiling slab v0.4.12
   Compiling iana-time-zone v0.1.65
   Compiling arrow-schema v53.4.1
   Compiling lock_api v0.4.14
   Compiling regex-syntax v0.8.11
   Compiling typenum v1.20.1
   Compiling byteorder v1.5.0
   Compiling futures-task v0.3.34
   Compiling lexical-util v1.0.7
   Compiling vcpkg v0.2.15
   Compiling ahash v0.8.12
   Compiling generic-array v0.14.7
   Compiling zmij v1.0.23
   Compiling ryu v1.0.23
   Compiling writeable v0.6.4
   Compiling percent-encoding v2.3.2
   Compiling httparse v1.10.1
   Compiling tracing-core v0.1.36
   Compiling hashbrown v0.15.5
   Compiling num-traits v0.2.19
   Compiling aho-corasick v1.1.5
   Compiling litemap v0.8.3
   Compiling serde_json v1.0.151
   Compiling subtle v2.6.1
   Compiling http v1.5.0
   Compiling rustversion v1.0.23
   Compiling crossbeam-utils v0.8.23
   Compiling tower-service v0.3.3
   Compiling thiserror v1.0.69
   Compiling icu_properties_data v2.3.0
   Compiling base64 v0.22.1
   Compiling icu_normalizer_data v2.3.0
   Compiling syn v3.0.6
   Compiling syn v2.0.119
   Compiling indexmap v2.14.2
   Compiling utf8_iter v1.0.4
   Compiling lexical-write-integer v1.0.6
   Compiling lexical-parse-integer v1.0.6
   Compiling rustix v1.1.5
   Compiling utf8parse v0.2.2
   Compiling fnv v1.0.7
   Compiling lazy_static v1.5.1
   Compiling foreign-types-shared v0.1.1
   Compiling semver v1.0.28
   Compiling linux-raw-sys v0.12.1
   Compiling lexical-parse-float v1.0.6
   Compiling openssl v0.10.81
   Compiling httpdate v1.0.3
   Compiling tower-layer v0.3.3
   Compiling atomic-waker v1.1.2
   Compiling crossbeam-epoch v0.9.21
   Compiling lexical-write-float v1.0.6
   Compiling cpufeatures v0.2.17
   Compiling try-lock v0.2.5
   Compiling jobserver v0.1.35
   Compiling rustc_version v0.4.1
   Compiling http-body v1.1.0
   Compiling foreign-types v0.3.2
   Compiling want v0.3.2
   Compiling anstyle-parse v1.0.0
   Compiling form_urlencoded v1.2.2
   Compiling errno v0.3.14
   Compiling socket2 v0.6.5
   Compiling mio v1.2.4
   Compiling regex-automata v0.4.18
   Compiling block-buffer v0.10.4
   Compiling crypto-common v0.1.7
   Compiling getrandom v0.2.17
   Compiling lexical-core v1.0.6
   Compiling cc v1.6.0
   Compiling http-body-util v0.1.5
   Compiling sync_wrapper v1.0.2
   Compiling futures-channel v0.3.34
   Compiling heck v0.5.0
   Compiling digest v0.10.7
   Compiling anstyle v1.0.14
   Compiling native-tls v0.2.18
   Compiling mime v0.3.17
   Compiling anstyle-query v1.1.5
   Compiling either v1.18.0
   Compiling crossbeam-deque v0.8.8
   Compiling paste v1.0.15
   Compiling num-integer v0.1.47
   Compiling num-complex v0.4.6
   Compiling signal-hook-registry v1.4.8
   Compiling rand_core v0.6.4
   Compiling atoi v2.0.0
   Compiling is_terminal_polyfill v1.70.2
   Compiling colorchoice v1.0.5
   Compiling flatbuffers v24.12.23
   Compiling proc-macro2-diagnostics v0.10.1
   Compiling thiserror v2.0.21
   Compiling anyhow v1.0.104
   Compiling rayon-core v1.13.0
   Compiling openssl-probe v0.2.1
   Compiling ipnet v2.12.2
   Compiling cfg_aliases v0.2.2
   Compiling num-bigint v0.4.8
   Compiling num-iter v0.1.46
   Compiling anstream v1.0.0
   Compiling crc32fast v1.5.2
   Compiling base64 v0.23.1
   Compiling sha1 v0.10.7
   Compiling encoding_rs v0.8.42
   Compiling simd-adler32 v0.3.10
   Compiling ucd-trie v0.1.7
   Compiling data-encoding v2.11.1
   Compiling same-file v1.0.6
   Compiling timely_container v0.27.0
   Compiling unicode-width v0.2.2
   Compiling yansi v1.0.1
   Compiling nix v0.30.1
   Compiling bitflags v1.3.2
   Compiling bytemuck v1.25.2
   Compiling synstructure v0.14.0
   Compiling cfg_aliases v0.1.1
   Compiling winnow v0.7.15
   Compiling adler2 v2.0.1
   Compiling utf-8 v0.7.6
   Compiling alloc-no-stdlib v2.0.4
   Compiling toml_write v0.1.2
   Compiling unicase v2.10.0
   Compiling zstd-safe v7.2.1
   Compiling jiff-core v0.1.1
   Compiling nix v0.28.0
   Compiling alloc-stdlib v0.2.4
   Compiling miniz_oxide v0.9.1
   Compiling getopts v0.2.24
   Compiling timely_logging v0.27.0
   Compiling walkdir v2.5.0
   Compiling rand_core v0.9.5
   Compiling csv-core v0.1.13
   Compiling uncased v0.9.10
   Compiling multiversion_no_op v1.0.0
   Compiling pest v2.9.2
   Compiling mime_guess v2.0.5
   Compiling snap v1.1.2
   Compiling num-rational v0.4.2
   Compiling getrandom v0.4.3
   Compiling timely_bytes v0.27.0
   Compiling core_detect v1.0.0
   Compiling simdutf8 v0.1.5
   Compiling zeroize v1.9.0
   Compiling flate2 v1.1.10
   Compiling csv v1.4.0
   Compiling openssl-sys v0.9.117
   Compiling zstd-sys v2.0.13+zstd.1.5.6
   Compiling tikv-jemalloc-sys v0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7
   Compiling rayon v1.12.0
   Compiling brotli-decompressor v4.0.3
   Compiling xattr v1.6.1
   Compiling jiff v0.2.37
   Compiling rustls-pki-types v1.15.1
   Compiling itertools v0.14.0
   Compiling ordered-float v2.10.1
   Compiling regex v1.13.1
   Compiling matchers v0.2.0
   Compiling filetime v0.2.29
   Compiling num v0.4.3
   Compiling num_cpus v1.17.0
   Compiling cpu-time v1.0.0
   Compiling sharded-slab v0.1.7
   Compiling tracing-log v0.2.0
   Compiling combine v4.6.8
   Compiling figment v0.10.19
   Compiling env_filter v2.0.0
   Compiling zerocopy-derive v0.8.60
   Compiling tracing-attributes v0.1.31
   Compiling thiserror-impl v1.0.69
   Compiling openssl-macros v0.1.1
   Compiling columnar_derive v0.11.1
   Compiling enum-as-inner v0.6.1
   Compiling pest_meta v2.9.2
   Compiling pear_codegen v0.2.9
   Compiling thread_local v1.1.10
   Compiling clap_lex v1.1.1
   Compiling base64ct v1.8.3
   Compiling powerfmt v0.2.1
   Compiling integer-encoding v3.0.4
   Compiling serde_derive v1.0.229
   Compiling zerofrom-derive v0.1.8
   Compiling yoke-derive v0.8.4
   Compiling zerovec-derive v0.11.6
   Compiling tokio-macros v2.7.2
   Compiling displaydoc v0.2.7
   Compiling futures-macro v0.3.34
   Compiling thiserror-impl v2.0.21
   Compiling async-trait v0.1.92
   Compiling nu-ansi-term v0.50.3
   Compiling inlinable_string v0.1.15
   Compiling time-core v0.1.9
   Compiling strsim v0.11.1
   Compiling num-conv v0.2.2
   Compiling endian-type v0.1.2
   Compiling static_assertions v1.1.0
   Compiling deranged v0.5.8
   Compiling twox-hash v2.1.5
   Compiling futures-util v0.3.34
   Compiling sysctl v0.6.0
   Compiling twox-hash v1.6.3
   Compiling pear v0.2.9
   Compiling clap_builder v4.6.7
   Compiling pest_generator v2.9.2
   Compiling tracing v0.1.44
   Compiling thrift v0.17.0
   Compiling lz4_flex v0.11.6
   Compiling password-hash v0.5.0
   Compiling clap_derive v4.6.7
   Compiling brotli v7.0.0
   Compiling tar v0.4.46
   Compiling env_logger v0.11.11
   Compiling zerofrom v0.1.8
   Compiling anndists v0.1.5
   Compiling yoke v0.8.3
   Compiling fd-lock v4.0.4
   Compiling sha2 v0.10.9
   Compiling blake2 v0.10.6
   Compiling serde_path_to_error v0.1.20
   Compiling zerovec v0.11.8
   Compiling zerotrie v0.2.5
   Compiling crossbeam-channel v0.5.17
   Compiling seq-macro v0.3.6
   Compiling matchit v0.7.3
   Compiling home v0.5.12
   Compiling time v0.3.55
   Compiling http-ra

... [13362 bytes truncated] ...

flow v0.20.0
   Compiling arrow-select v53.4.1
   Compiling arrow-row v53.4.1
   Compiling arrow-arith v53.4.1
   Compiling h2 v0.4.19
   Compiling tower-http v0.5.2
   Compiling tower-http v0.6.11
   Compiling arrow-cast v53.4.1
   Compiling arrow-ord v53.4.1
   Compiling arrow-string v53.4.1
   Compiling arrow-ipc v53.4.1
   Compiling arrow-json v53.4.1
   Compiling arrow-csv v53.4.1
   Compiling hyper v1.11.1
   Compiling arrow v53.4.1
   Compiling zstd v0.13.3
   Compiling parquet v53.4.1
   Compiling hyper-util v0.1.21
   Compiling hyper-tls v0.6.0
   Compiling axum v0.7.9
   Compiling reqwest v0.12.28
   Compiling inputlayer-ontology-client v0.1.0 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M47C9D1HYNPRHQFEKNS2N22C/ontology-client)
   Compiling tikv-jemallocator v0.6.1
   Compiling inputlayer v0.1.0 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M47C9D1HYNPRHQFEKNS2N22C)
    Finished `release` profile [optimized] target(s) in 3m 27s
=== Measure (baseline d5df1ba3e25858c38a2f9c53a072c85e5d41e9d5 vs candidate 8be9c5dc5cec2cab50bd26320dc7753684f26ea0) ===
round 1/6 recursive_query baseline  latency_us p50 449.82ms p99 675.65ms
round 1/6 recursive_query candidate latency_us p50 372.58ms p99 699.39ms
round 2/6 recursive_query candidate latency_us p50 290.58ms p99 365.22ms
round 2/6 recursive_query baseline  latency_us p50 402.17ms p99 501.49ms
round 3/6 recursive_query baseline  latency_us p50 388.58ms p99 637.03ms
round 3/6 recursive_query candidate latency_us p50 343.98ms p99 536.68ms
round 4/6 recursive_query candidate latency_us p50 313.31ms p99 427.44ms
round 4/6 recursive_query baseline  latency_us p50 411.79ms p99 766.32ms
round 5/6 recursive_query baseline  latency_us p50 606.98ms p99 1219.91ms
round 5/6 recursive_query candidate latency_us p50 438.84ms p99 612.97ms
round 6/6 recursive_query candidate latency_us p50 362.56ms p99 423.11ms
round 6/6 recursive_query baseline  latency_us p50 427.99ms p99 618.63ms
run file: ~/.no-mistakes/worktrees/9eaf1dfa27af/01M47C9D1HYNPRHQFEKNS2N22C/target/perf-gate/runs/20261006T062322Z/run.json
=== Judge ===
## Performance gate: INVALID

Policy **provisional**: cost budget p50 +5%, p99 +10%, throughput -5%; 95% interval of the median paired per-round ratio. Budgets are the plan's proposed ceilings, pending captain decision D1; tightening is always allowed.

- baseline: `d5df1ba3e25858c38a2f9c53a072c85e5d41e9d5` (binary sha256 70ce7b00824a)
- candidate: `8be9c5dc5cec2cab50bd26320dc7753684f26ea0` (binary sha256 b0a6ce172292)
- profile `standard`, 6 rounds, fixtures recursive_query
- host `computeinstance-e00w59r9a240k9qz1e`: AMD EPYC-Genoa Processor (32 CPUs, 125 GB), kernel 6.11.0-1016-nvidia, governor unknown, data on ext4 (~/.no-mistakes/worktrees/9eaf1dfa27af/01M47C9D1HYNPRHQFEKNS2N22C/target/perf-gate/runs/20261006T062322Z/servers), server CPUs unpinned
- load average: start `57.78 50.43 40.71 92/1989 908974`, end `52.70 52.80 43.35 67/1931 1002036`
- **busy host**: 1-minute load 58 on 32 CPUs; results carry other work's noise
- build: rustc 1.99.0 (b940084d7 2026-09-28); cargo build --release --all-features

\### Problems

- bound_query.concurrent_latency_us.p99: baseline: 0 valid rounds, need 6
- bound_query.latency_us.p50: baseline: 0 valid rounds, need 6
- bound_query.latency_us.p99: baseline: 0 valid rounds, need 6
- bound_query.queries_per_sec: baseline: 0 valid rounds, need 6
- cheap_query.latency_us.p50: baseline: 0 valid rounds, need 6
- cheap_query.latency_us.p99: baseline: 0 valid rounds, need 6
- cheap_query.queries_per_sec: baseline: 0 valid rounds, need 6
- delta_fanout.delta_us.p50: baseline: 0 valid rounds, need 6
- delta_fanout.delta_us.p99: baseline: 0 valid rounds, need 6
- delta_fanout.last_agent_us.p99: baseline: 0 valid rounds, need 6
- delta_first.first_delta_us.p50: baseline: 0 valid rounds, need 6
- delta_first.warm_delta_us.p50: baseline: 0 valid rounds, need 6
- delta_keyed.ack_us.p99: baseline: 0 valid rounds, need 6
- delta_keyed.delta_us.p50: baseline: 0 valid rounds, need 6
- delta_keyed.delta_us.p99: baseline: 0 valid rounds, need 6
- delta_keyed.writes_per_server_cpu_sec: baseline: 0 valid rounds, need 6
- delta_single.delta_us.p50: baseline: 0 valid rounds, need 6
- delta_single.delta_us.p99: baseline: 0 valid rounds, need 6
- insert_batch.ack_us.p50: baseline: 0 valid rounds, need 6
- insert_batch.facts_per_sec: baseline: 0 valid rounds, need 6
- insert_single.ack_us.p50: baseline: 0 valid rounds, need 6
- insert_single.ack_us.p99: baseline: 0 valid rounds, need 6
- insert_single.concurrent_facts_per_sec: baseline: 0 valid rounds, need 6
- insert_single.facts_per_sec: baseline: 0 valid rounds, need 6
- interference.delta_us.p50: baseline: 0 valid rounds, need 6
- interference.delta_us.p99: baseline: 0 valid rounds, need 6

\### Required metrics

| metric | baseline | candidate | cost ratio | interval | budget | baseline spread | status |
|---|---|---|---|---|---|---|---|
| bound_query.concurrent_latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| bound_query.latency_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| bound_query.latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| bound_query.queries_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| cheap_query.latency_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| cheap_query.latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| cheap_query.queries_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_fanout.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_fanout.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_fanout.last_agent_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_first.first_delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_first.warm_delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.ack_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_keyed.writes_per_server_cpu_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_single.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| delta_single.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_batch.ack_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_batch.facts_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.ack_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.ack_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.concurrent_facts_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| insert_single.facts_per_sec | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| interference.delta_us.p50 | - | - | - | - | 1.05 | - | INVALID: baseline: 0 valid rounds, need 6 |
| interference.delta_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline: 0 valid rounds, need 6 |
| recursive_query.latency_us.p50 | 419.889 ms | 353.270 ms | 0.795 | 0.723..0.885 | 1.05 | 52.0% | PASS |

\### Diagnostic metrics (not gated)

| metric | baseline | candidate | cost ratio | interval | budget | baseline spread | status |
|---|---|---|---|---|---|---|---|
| recursive_query.latency_us.p99 | - | - | - | - | 1.10 | - | INVALID: baseline round 1: 25 samples, need 100 |

Report: ~/.no-mistakes/worktrees/9eaf1dfa27af/01M47C9D1HYNPRHQFEKNS2N22C/target/perf-gate/runs/20261006T062322Z/report.md (attach to the PR record)
exit=3
Evidence: Memory returned after a second of quiet (change vs base)

[candidate] idle RSS before the stream: 44 MiB [candidate] RSS while the stream runs: peak 161 MiB, lowest 106 MiB [candidate] RSS 1000 ms after the last read: 138 MiB [candidate] RSS 1250 ms after the last read: 44 MiB [candidate] RSS fell halfway to its final level 1012 ms after the last read [base] RSS while the stream runs: peak 144 MiB, lowest 53 MiB [base] RSS fell halfway to its final level 15 ms after the last read

[candidate   33.63s] idle RSS before the stream: 44 MiB
[candidate   39.96s] reads (ms): 293 336 262 243 264 283 238 410; rows each: 1002
[candidate   39.96s] RSS while the stream runs: peak 161 MiB, lowest 106 MiB
[candidate   39.96s]   RSS  250 ms after the last read: 138 MiB
[candidate   39.96s]   RSS  500 ms after the last read: 138 MiB
[candidate   39.96s]   RSS  750 ms after the last read: 138 MiB
[candidate   39.96s]   RSS 1000 ms after the last read: 138 MiB
[candidate   39.96s]   RSS 1250 ms after the last read: 44 MiB
[candidate   39.96s]   RSS 1500 ms after the last read: 44 MiB
[candidate   39.96s]   RSS 2000 ms after the last read: 44 MiB
[candidate   39.96s]   RSS 3000 ms after the last read: 44 MiB
[candidate   39.96s]   RSS 3900 ms after the last read: 44 MiB
[candidate   39.96s] RSS fell halfway to its final level 1012 ms after the last read
[base   34.08s] idle RSS before the stream: 44 MiB
[base   40.50s] reads (ms): 344 286 303 282 332 295 284 294; rows each: 1002
[base   40.50s] RSS while the stream runs: peak 144 MiB, lowest 53 MiB
[base   40.50s]   RSS  250 ms after the last read: 44 MiB
[base   40.50s]   RSS  500 ms after the last read: 44 MiB
[base   40.50s]   RSS  750 ms after the last read: 44 MiB
[base   40.50s]   RSS 1000 ms after the last read: 44 MiB
[base   40.50s]   RSS 1250 ms after the last read: 44 MiB
[base   40.50s]   RSS 1500 ms after the last read: 44 MiB
[base   40.50s]   RSS 2000 ms after the last read: 44 MiB
[base   40.50s]   RSS 3000 ms after the last read: 44 MiB
[base   40.50s]   RSS 3900 ms after the last read: 44 MiB
[base   40.50s] RSS fell halfway to its final level 15 ms after the last read
Evidence: Five-second cap under a stream that never goes quiet (change vs base)

[candidate] ?edge(X, Y), label(A, B) -> resource_exhausted after 5662 ms; RSS peaked at 4200 MiB [candidate] then 34 ?hot_reach(X, Y) reads back to back; longest gap between two large computations finishing: 363 ms [candidate] 4.5-5.0 s after the big query: RSS 1120..1157 MiB [candidate] 5.0-5.5 s after the big query: RSS 146..1155 MiB [candidate] 5.5-6.0 s after the big query: RSS 199..233 MiB [base] 0.0-0.5 s after the big query: RSS 114..209 MiB

[candidate   13.65s] idle RSS before: 42 MiB
[candidate   19.31s] ?edge(X, Y), label(A, B) -> {"type":"error","message":"Request exceeded the per-query memory limit (storage.performance.max_query_memory_bytes) before it began committing; nothing was applied. Narrow the query or bind more of its arguments","code":"resource_exhausted"} after 5662 ms; RSS peaked at 4200 MiB
[candidate   28.48s] then 34 ?hot_reach(X, Y) reads back to back; longest gap between two large computations finishing: 363 ms
[candidate   28.48s]   0.0-0.5 s after the big query: RSS 2919..3815 MiB
[candidate   28.48s]   0.5-1.0 s after the big query: RSS 999..2864 MiB
[candidate   28.48s]   1.0-1.5 s after the big query: RSS 1006..1016 MiB
[candidate   28.48s]   1.5-2.0 s after the big query: RSS 1005..1013 MiB
[candidate   28.48s]   2.0-2.5 s after the big query: RSS 1012..1123 MiB
[candidate   28.48s]   2.5-3.0 s after the big query: RSS 1107..1141 MiB
[candidate   28.48s]   3.0-3.5 s after the big query: RSS 1114..1155 MiB
[candidate   28.48s]   3.5-4.0 s after the big query: RSS 1133..1153 MiB
[candidate   28.48s]   4.0-4.5 s after the big query: RSS 1128..1156 MiB
[candidate   28.48s]   4.5-5.0 s after the big query: RSS 1120..1157 MiB
[candidate   28.48s]   5.0-5.5 s after the big query: RSS 146..1155 MiB
[candidate   28.48s]   5.5-6.0 s after the big query: RSS 199..233 MiB
[candidate   28.48s]   6.0-6.5 s after the big query: RSS 196..227 MiB
[candidate   28.48s]   6.5-7.0 s after the big query: RSS 203..232 MiB
[candidate   28.48s]   7.0-7.5 s after the big query: RSS 208..231 MiB
[candidate   28.48s]   7.5-8.0 s after the big query: RSS 214..239 MiB
[candidate   28.48s]   8.0-8.5 s after the big query: RSS 215..234 MiB
[candidate   28.48s]   8.5-9.0 s after the big query: RSS 215..230 MiB
[base   12.90s] idle RSS before: 45 MiB
[base   18.73s] ?edge(X, Y), label(A, B) -> {"type":"error","message":"Request exceeded the per-query memory limit (storage.performance.max_query_memory_bytes) before it began committing; nothing was applied. Narrow the query or bind more of its arguments","code":"resource_exhausted"} after 5831 ms; RSS peaked at 4204 MiB
[base   27.93s] then 32 ?hot_reach(X, Y) reads back to back; longest gap between two large computations finishing: 564 ms
[base   27.93s]   0.0-0.5 s after the big query: RSS 114..209 MiB
[base   27.93s]   0.5-1.0 s after the big query: RSS 108..208 MiB
[base   27.93s]   1.0-1.5 s after the big query: RSS 112..209 MiB
[base   27.93s]   1.5-2.0 s after the big query: RSS 112..208 MiB
[base   27.94s]   2.0-2.5 s after the big query: RSS 115..209 MiB
[base   27.94s]   2.5-3.0 s after the big query: RSS 109..209 MiB
[base   27.94s]   3.0-3.5 s after the big query: RSS 111..209 MiB
[base   27.94s]   3.5-4.0 s after the big query: RSS 113..209 MiB
[base   27.94s]   4.0-4.5 s after the big query: RSS 109..208 MiB
[base   27.94s]   4.5-5.0 s after the big query: RSS 118..208 MiB
[base   27.94s]   5.0-5.5 s after the big query: RSS 109..209 MiB
[base   27.94s]   5.5-6.0 s after the big query: RSS 111..210 MiB
[base   27.94s]   6.0-6.5 s after the big query: RSS 109..208 MiB
[base   27.94s]   6.5-7.0 s after the big query: RSS 109..209 MiB
[base   27.94s]   7.0-7.5 s after the big query: RSS 116..208 MiB
[base   27.94s]   7.5-8.0 s after the big query: RSS 109..202 MiB
[base   27.94s]   8.0-8.5 s after the big query: RSS 109..209 MiB
[base   27.94s]   8.5-9.0 s after the big query: RSS 115..209 MiB
Evidence: Sudden RSS drops during 14 s of back-to-back reads (change 5, base 39)
[candidate   14.44s] idle RSS before the stream: 43 MiB
[candidate   28.66s] 42 reads back to back; longest gap between two finishing: 752 ms
[candidate   28.66s] read latency p50 287 ms
[candidate   28.66s] RSS drops of >= 20 MiB within 50 ms while the stream runs: 5
[candidate   28.66s]   -22 MiB at 0.54 s into the stream (0.26 s after the first read finished)
[candidate   28.66s]   -25 MiB at 1.84 s into the stream (1.55 s after the first read finished)
[candidate   28.66s]   -21 MiB at 5.30 s into the stream (5.01 s after the first read finished)
[candidate   28.66s]   -63 MiB at 10.36 s into the stream (10.07 s after the first read finished)
[candidate   28.66s]   -20 MiB at 12.75 s into the stream (12.47 s after the first read finished)
[base   26.00s] idle RSS before the stream: 44 MiB
[base   40.16s] 49 reads back to back; longest gap between two finishing: 358 ms
[base   40.16s] read latency p50 284 ms
[base   40.16s] RSS drops of >= 20 MiB within 50 ms while the stream runs: 39
[base   40.16s]   -38 MiB at 0.29 s into the stream (-0.01 s after the first read finished)
[base   40.16s]   -90 MiB at 0.59 s into the stream (0.30 s after the first read finished)
[base   40.16s]   -62 MiB at 0.89 s into the stream (0.60 s after the first read finished)
[base   40.16s]   -48 MiB at 1.20 s into the stream (0.90 s after the first read finished)
[base   40.16s]   -99 MiB at 1.74 s into the stream (1.44 s after the first read finished)
[base   40.16s]   -75 MiB at 2.04 s into the stream (1.75 s after the first read finished)
[base   40.16s]   -51 MiB at 2.34 s into the stream (2.05 s after the first read finished)
[base   40.16s]   -22 MiB at 2.87 s into the stream (2.58 s after the first read finished)
[base   40.16s]   -85 MiB at 3.17 s into the stream (2.88 s after the first read finished)
[base   40.16s]   -31 MiB at 3.48 s into the stream (3.18 s after the first read finished)
[base   40.16s]   -24 MiB at 4.00 s into the stream (3.71 s after the first read finished)
[base   40.16s]   -99 MiB at 4.31 s into the stream (4.01 s after the first read finished)
[base   40.16s]   ... and 27 more
Evidence: Guards still stop queries on the change

max_query_memory_bytes = 16777216 ?hot_reach(X, Y) -> {"type":"error","message":"Request exceeded the per-query memory limit (storage.performance.max_query_memory_bytes) before it began committing; nothing was applied. ...","code":"resource_exhausted"} after 69 ms ?edge(0, Y) afterwards -> {"rows":1} query_timeout_ms = 2000 ?count(X) (recursion with no fixpoint) -> {"type":"error","message":"Request deadline exceeded before it began committing; nothing was applied","code":"deadline_exceeded"} after 2002 ms ?edge(0, Y) afterwards -> {"rows":1}

[candidate    0.84s] max_query_memory_bytes = 16777216
[candidate    0.91s] ?hot_reach(X, Y) -> {"type":"error","message":"Request exceeded the per-query memory limit (storage.performance.max_query_memory_bytes) before it began committing; nothing was applied. Narrow the query or bind more of its arguments","code":"resource_exhausted"} after 69 ms
[candidate    0.92s] ?edge(0, Y) afterwards -> {"rows":1}
[candidate    0.92s] server RSS 85 MiB
[candidate    0.47s] query_timeout_ms = 2000
[candidate    0.48s] ?hot_reach(X, Y) (3K edges) -> {"rows":30}, expected 30
[candidate    0.49s] +n(0) -> {"rows":1} after 8 ms
[candidate    0.51s] +count(X) <- n(X) -> {"rows":1} after 14 ms
[candidate    0.52s] +count(Y) <- count(X), Y = X + 1 -> {"rows":1} after 18 ms
[candidate    2.52s] ?count(X) (recursion with no fixpoint) -> {"type":"error","message":"Request deadline exceeded before it began committing; nothing was applied","code":"deadline_exceeded"} after 2002 ms
[candidate    2.53s] ?edge(0, Y) afterwards -> {"rows":1}
  • Evidence: WebSocket driver used for the memory and guard scenarios (local file: ~/.no-mistakes/evidence/01M47C9D1HYNPRHQFEKNS2N22C/drive.mjs)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 1 issue found → auto-fixed ✅
  • ⚠️ gui/lib/docs-bundle.ts - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ src/protocol/handler/supervise.rs:98 - Intent conformance: the intent says "Make the check cheaper (check every N steps) without weakening the guard". The change leaves the per-step deadline/memory checks untouched and instead changes a different part of fix: refuse or stop runaway cross-product queries within their limits and release their memory #304: the post-query release of freed memory (if job_control.memory_peak() &gt; memory::RELEASE_AFTER_PEAK_BYTES { RELEASER.finished_large(RELEASE_QUIET, memory::release_freed_memory); } replaces the immediate memory::release_freed_memory() before the permit drops). The commit message justifies this with host measurements (checks stubbed: 275.7 vs 275.1 ms; release stubbed: 223.5 ms), which I cannot verify from source. The intent's diagnosis was hedged ("Likely"), but the prescribed remedy was not implemented and a user-visible memory behaviour (documented in deployment.mdx) was changed instead. Please confirm that deferring the release is the accepted fix for Recursive reads 9-20% slower since #304 (runaway-query guards): ?hot_reach(X,Y) 248 -> 301 ms at 100k edges #384 and that it does not count as weakening the guard.
  • ⚠️ src/protocol/handler/supervise.rs:143 - The release is a pure debounce with no upper bound: every large computation pushes last_large forward and the task loops on sleep_until(last_large + quiet). Under a sustained stream where a >64 MiB computation finishes at least once per second (exactly the workload this optimises, e.g. clients looping ~250 ms recursive reads), release never runs. Concrete sequence: one query peaks near its memory limit, then a steady stream of ~70 MiB reads follows on other blocking-pool threads (other jemalloc arenas); the big query's freed pages are not reused by the stream and are no longer purged by the server, only by jemalloc's own ~10 s decay, which is the situation fix: refuse or stop runaway cross-product queries within their limits and release their memory #304 added the explicit release for. Before this change they were purged before the permit admitted the next computation. The doc comment and commit claim "resident memory meanwhile is bounded as before, by the memory limits" and "goes back within a second of the last one", but the limits count live bytes, not dirty pages in idle arenas, so RSS can sit above them for longer than before. The limits themselves (accounted bytes, not RSS) are not weakened. Smallest remedy would be a cap on total deferral (release at the latest N seconds after the first pending large computation); that changes the chosen trade-off, so it needs the author's decision.
  • ℹ️ src/protocol/handler/supervise.rs:430 - freed_memory_is_released_once_large_computations_go_quiet relies on wall-clock sleeps with a 70 ms margin: the RELEASED == 0 assertion fails if the five 30 ms sleeps overshoot by a total of 70 ms after the last finished_large, and the == 1 / == 2 assertions fail if the spawned task plus spawn_blocking take longer than ~80-100 ms to be scheduled on a loaded CI runner. The crate already enables tokio test-util and uses #[tokio::test(start_paused = true)] elsewhere (src/protocol/replication/follower.rs:648); MemoryReleaser uses tokio::time::sleep_until, but compares against std::time::Instant::now(), so making the test deterministic means using tokio::time::Instant in the releaser as well. The started.elapsed() &gt;= 4*30ms + quiet assertion at line 433 is always true given the sleeps before it and proves nothing.

🔧 Fix applied.
4 issues (2 warnings, 2 infos) still open:

  • ⚠️ src/protocol/handler/supervise.rs:98 - Intent conformance: the intent says "Make the check cheaper (check every N steps) without weakening the guard". The change leaves the per-step deadline/memory checks untouched and instead changes a different part of fix: refuse or stop runaway cross-product queries within their limits and release their memory #304: the post-query release of freed memory (if job_control.memory_peak() &gt; memory::RELEASE_AFTER_PEAK_BYTES { RELEASER.finished_large(RELEASE_QUIET, memory::release_freed_memory); } replaces the immediate memory::release_freed_memory() before the permit drops). The commit message justifies this with host measurements (checks stubbed: 275.7 vs 275.1 ms; release stubbed: 223.5 ms), which I cannot verify from source. The intent's diagnosis was hedged ("Likely"), but the prescribed remedy was not implemented and a user-visible memory behaviour (documented in deployment.mdx) was changed instead. Please confirm that deferring the release is the accepted fix for Recursive reads 9-20% slower since #304 (runaway-query guards): ?hot_reach(X,Y) 248 -> 301 ms at 100k edges #384 and that it does not count as weakening the guard.
  • ⚠️ src/protocol/handler/supervise.rs:143 - The release is a pure debounce with no upper bound: every large computation pushes last_large forward and the task loops on sleep_until(last_large + quiet). Under a sustained stream where a >64 MiB computation finishes at least once per second (exactly the workload this optimises, e.g. clients looping ~250 ms recursive reads), release never runs. Concrete sequence: one query peaks near its memory limit, then a steady stream of ~70 MiB reads follows on other blocking-pool threads (other jemalloc arenas); the big query's freed pages are not reused by the stream and are no longer purged by the server, only by jemalloc's own ~10 s decay, which is the situation fix: refuse or stop runaway cross-product queries within their limits and release their memory #304 added the explicit release for. Before this change they were purged before the permit admitted the next computation. The doc comment and commit claim "resident memory meanwhile is bounded as before, by the memory limits" and "goes back within a second of the last one", but the limits count live bytes, not dirty pages in idle arenas, so RSS can sit above them for longer than before. The limits themselves (accounted bytes, not RSS) are not weakened. Smallest remedy would be a cap on total deferral (release at the latest N seconds after the first pending large computation); that changes the chosen trade-off, so it needs the author's decision.
  • ℹ️ src/protocol/handler/supervise.rs:430 - freed_memory_is_released_once_large_computations_go_quiet relies on wall-clock sleeps with a 70 ms margin: the RELEASED == 0 assertion fails if the five 30 ms sleeps overshoot by a total of 70 ms after the last finished_large, and the == 1 / == 2 assertions fail if the spawned task plus spawn_blocking take longer than ~80-100 ms to be scheduled on a loaded CI runner. The crate already enables tokio test-util and uses #[tokio::test(start_paused = true)] elsewhere (src/protocol/replication/follower.rs:648); MemoryReleaser uses tokio::time::sleep_until, but compares against std::time::Instant::now(), so making the test deterministic means using tokio::time::Instant in the releaser as well. The started.elapsed() &gt;= 4*30ms + quiet assertion at line 433 is always true given the sleeps before it and proves nothing.
  • ℹ️ src/protocol/handler/supervise.rs:161 - Latent, not reachable in the shipped server: RELEASER is a process-wide static, but the task that clears pending is spawned on whichever Tokio runtime the large computation ran on. If that runtime is dropped while a release is pending (within 1-5 s of a >64 MiB computation), the task is dropped with pending still Some, and every later finished_large in the process only updates last and returns, so the explicit release never runs again (jemalloc's own ~10 s decay still applies). The server binary builds exactly one runtime (src/bin/server.rs:65), so production is unaffected; the only processes that take this path are test binaries where each #[tokio::test] has its own runtime, and no test asserts on resident memory. Introduced by the original commit and kept by fix round 1 (which reshaped the state into Pending { first, last }). No action needed for this change; a drop guard in the spawned task that resets pending would close it if the handler is ever driven from more than one runtime.

✅ No issues found.

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 6 scenarios driven live against the product
Scenario Result Live Evidence
A client reads ?hot_reach(X, Y) over 100K edges repeatedly; the changed server answers faster than the base commit ✅ pass live perf-gate-report.md: recursive_query.latency_us.p50 baseline 295.247 ms, candidate 235.908 ms, cost ratio 0.822, interval 0.791..0.908, PASS. Hand-driven: median 304 ms (release-baseline.txt) vs 233 m…
The perf gate runs the new recursive_query case against real servers, checks the 1,002-row answer and judges recursive_query.latency_us.p50 as a required metric ✅ pass live perf-gate-recursive.log and perf-gate-report.md: the metric appears under Required metrics with status PASS; perf-gate-summary.md lists 300 latency samples over 12 server runs
After a burst of large reads stops, the server returns the freed memory to the operating system within about a second, and does not purge between reads of the burst ✅ pass live release-candidate.txt: resident 137-165 MiB across reads 2-8, 137 MiB for 1 s after the last reply, then 46 MiB (idle level) at 1.01 s. release-baseline.txt: the base server drops to 45-69 MiB inside…
A stream of large reads that never goes quiet still has its freed memory returned at the five-second cap ✅ pass live stream-candidate.txt: over 51 back-to-back reads, resident-memory minima of 112 MiB at 5.25 s and 111 MiB at 10.25 s against about 140-150 MiB otherwise. This is a modest live signal; the exact timing…
Adversarial: a recursive read given a 20 ms deadline right after large reads (a release pending) is still stopped at its deadline, and the server keeps answering ✅ pass live guard-deadline.txt: deadline_exceeded after 22 ms; the next full read returns 1,002 rows
Adversarial: with the per-query memory limit set to 32 MiB, repeated recursive reads are each refused with resource_exhausted and a small read still answers ✅ pass live guard-memory.txt: five reads refused after 45-56 ms each with resource_exhausted; ?edge(0, Y) returns 1 row
  • scripts/perf-gate.sh --baseline-rev 0056538513506df1f691369e28737e5ff6534436 --fixtures recursive_query --rounds 6 (release builds of base and candidate, real servers over /ws)
  • node drive.mjs &lt;candidate-server&gt; &lt;scratch&gt; release and the same against the base-commit server: 8 back-to-back ?hot_reach(X, Y) reads over 100K edges with resident memory sampled every 20 ms from /proc
  • node drive.mjs &lt;candidate-server&gt; &lt;scratch&gt; stream: 51 reads back to back for 12 s with resident memory sampled
  • node drive.mjs &lt;candidate-server&gt; &lt;scratch&gt; guard-deadline: ?hot_reach(X, Y) with timeout_ms: 20 after three large reads
  • node drive.mjs <candidate-server> <scratch> guard-memory INPUTLAYER_STORAGE__PERFORMANCE__MAX_QUERY_MEMORY_BYTES=33554432
  • cargo test --lib protocol::handler::supervise (8 tests, including the two paused-time releaser tests)

✅ No issues found.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
A client reads ?hot_reach(X, Y) over 100K edges and gets the answer faster than on the base commit, with the correct 1,002 rows ✅ pass live recursive-report.md / perf-gate-recursive.log: recursive_query.latency_us.p50 baseline 282.534 ms, candidate 236.505 ms, cost ratio 0.833 (interval 0.813..0.861), 8 paired rounds, row counts checked o…
The perf gate judges the recursive read: recursive_query.latency_us.p50 appears among the required metrics and gets a verdict ✅ pass live recursive-report.md 'Required metrics' table: recursive_query.latency_us.p50, budget 1.05, status PASS; perf-gate run --help shows gate as the default group
After one large read and then quiet, the server returns the freed memory to the OS about one second later ✅ pass live release-candidate.txt section A: reply at 0.57 s, resident 127 MB until an 83 MB drop at 1.60 s (1.03 s after the reply), then 45 MB; release-base.txt shows base dropping at the reply itself
Adversarial: a stream of large reads that never goes quiet still has its freed memory returned, at the five-second cap, and a second after it ends ✅ pass live release-candidate.txt section B: 46 back-to-back reads over 12.5 s; release drops at 5.31 s and 10.53 s, then 106 MB freed 1.02 s after the last reply, ending at 46 MB (idle level). One further 45 MB…
Adversarial: a recursive read that exceeds the per-query or server-wide memory limit is still refused with resource_exhausted, and the server keeps serving ✅ pass live guards-candidate.txt: refused after 42-56 ms with resource_exhausted under both 32 MiB limits, then ?edge(0, Y) answers in about 1 ms; guards-base.txt shows the same codes and comparable timing
Adversarial: a recursive read past its deadline, and a recursion with no fixpoint, are still stopped with deadline_exceeded on time ✅ pass live guards-candidate.txt: ?hot_reach(X, Y) refused at 61-62 ms under a 60 ms deadline; ?count(N) refused at 2002 ms under a 2 s deadline; server answers the next query; identical on base
The releaser's timing rules hold under controlled time: one shared release after quiet, and a release at the cap for a sustained stream ⏸️ untested no The prior payload did not establish a live result for this scenario: it was exercised only by cargo test --lib protocol::handler::supervise, a unit test under paused Tokio time, which is not a run a…
  • scripts/perf-gate.sh --baseline-rev c3d08ae5491824c3b21ba314ec9e38073845c66b --fixtures recursive_query --rounds 8 --server-cpus 8-31 --gate-cpus 0-7 (builds both release servers, paired measurement, judged under perf-gate/policy.toml)
  • node drive.mjs release &lt;candidate inputlayer-server&gt;: 100K-edge graph, one ?hot_reach(X, Y) then quiet, then 12.5 s of back-to-back reads, resident memory sampled every 50 ms
  • node drive.mjs release &lt;base inputlayer-server&gt;: the same sequence on the base commit for comparison
  • node drive.mjs guards &lt;candidate inputlayer-server&gt; and the same on base: ?hot_reach(X, Y) under MAX_QUERY_MEMORY_BYTES=32 MiB, MAX_TOTAL_QUERY_MEMORY_BYTES=32 MiB, QUERY_TIMEOUT_MS=60, and ?count(N) of a recursion with no fixpoint under QUERY_TIMEOUT_MS=2000, each followed by a small query
  • cargo test --lib protocol::handler::supervise (includes the two paused-time MemoryReleaser tests)
  • perf-gate run --help: confirms gate is the default fixture group, which now contains recursive_query

✅ No issues found.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
A client reads ?hot_reach(X, Y) over 100K edges repeatedly; the read is faster than at the base commit and returns the right 1,002 rows ✅ pass live scripts/perf-gate.sh --baseline-rev d5df1ba3 --fixtures recursive_query --rounds 6: p50 419.9 ms base vs 353.3 ms change, cost ratio 0.795 (interval 0.723..0.885); recursive-report.md
The perf gate measures and judges the new recursive_query case as a required metric ✅ pass live Gate report lists recursive_query.latency_us.p50 under Required metrics with status PASS and row counts checked on every timed reply; recursive-report.md
After a stream of large recursive reads stops, the server returns the freed memory to the operating system within about a second ✅ pass live rss-quiet.log: change holds 138 MiB until 1000 ms after the last read and is back at its 44 MiB idle level by 1250 ms (halfway point at 1012 ms)
During a stream of large reads the server no longer purges memory after every read ✅ pass live rss-quiet.log and rss-cap.log: lowest RSS during the stream 106 MiB on the change vs 53 MiB at base; sudden drops of 20 MiB or more in 14 s of reads: 5 on the change vs 39 at base
Adversarial: large reads never go quiet after a big query; the memory the big query freed is still returned at the five-second cap ✅ pass live rss-cap-big.log: 34 back-to-back reads with at most 363 ms between finishes; RSS stays at 1.1 GiB until 5.0 s after the big query, then falls to about 200 MiB
Adversarial: a query that outgrows its memory limit is still refused with resource_exhausted and the server keeps serving ✅ pass live guards.log: ?hot_reach(X, Y) refused after 69 ms at a 16 MiB limit, ?edge(0, Y) answers afterwards; rss-cap-big.log: a cross join refused at the default 4 GiB limit with RSS peaking at 4.2 GiB
Adversarial: recursion that never reaches a fixpoint is still stopped at the query deadline ✅ pass live guards.log: ?count(X) over count(Y) &lt;- count(X), Y = X + 1 refused with deadline_exceeded after 2002 ms at query_timeout_ms = 2000; the next query answers
The releaser releases once after a quiet second and at the cap under a sustained stream (controlled time) ⏸️ untested no The prior payload did not establish a live result for this scenario: it was exercised only by cargo test --lib protocol::handler::supervise, a unit test under paused Tokio time, which is not the run…
  • scripts/perf-gate.sh --baseline-rev d5df1ba3e25858c38a2f9c53a072c85e5d41e9d5 --fixtures recursive_query --rounds 6 (release servers of base and change, paired and interleaved, standard profile)
  • node drive.mjs &lt;server&gt; rss-quiet on change and base: 8 serial ?hot_reach(X, Y) reads over 100K edges, server RSS sampled every 25 ms during and for 4 s after
  • node drive.mjs &lt;server&gt; rss-cap on change and base: 14 s of back-to-back reads, sudden RSS drops counted
  • node drive.mjs &lt;server&gt; rss-cap-big on change and base: a query run into the default 4 GiB limit, then 9 s of back-to-back reads, RSS per half second
  • node drive.mjs &lt;candidate&gt; guard-memory: max_query_memory_bytes set to 16 MiB, then ?hot_reach(X, Y)
  • node drive.mjs &lt;candidate&gt; guard-deadline: query_timeout_ms set to 2000, then a recursive counter rule with no fixpoint
  • cargo test --lib protocol::handler::supervise
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

✅ No issues found.

@jsam
jsam force-pushed the fm/il-384-recursive-reads branch from b3a98a1 to 669d480 Compare October 6, 2026 05:18
jsam added 3 commits October 6, 2026 06:13
…, not after every one (#384)

Recursive reads were 9-20% slower since #304. The bisect blamed the
per-step deadline and memory checks inside recursion, but on the
benchmark host at main they cost nothing (?hot_reach(X,Y) over 100K edges:
275.7 ms with them, 275.1 ms with them stubbed). The cost is the release
of freed memory #304 added: after every computation that peaked over
64 MiB, the server purged the allocator's freed pages on the reply path,
and the next read faulted the same pages back in. With that release
stubbed the same read takes 223.5 ms (-19%), ?path(400,Y) 220.7 ms
instead of 265.4 ms.

The release now waits until no large computation has finished for one
second, then runs once on the blocking pool: a stream of large reads
keeps reusing its pages, and the memory goes back to the operating system
within a second of the last one. The stop guards are untouched, and the
memory a stream holds meanwhile is bounded as before by the limits its
computations ran under; resident memory can stay at the stream's working
size for up to a second longer than before.

The perf gate now has the case: `recursive_query`, `?hot_reach(X, Y)` of
the views benchmark's rules over the delta_keyed graph (100K edges), 25
serial reads per round, judged on p50.
@jsam
jsam force-pushed the fm/il-384-recursive-reads branch from 669d480 to 8be9c5d Compare October 6, 2026 06:36
@jsam

jsam commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@jsam jsam closed this Oct 6, 2026
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.

1 participant