Repository navigation
Conversation
…ations Each standing-query re-evaluation recompiled its query with every persistent rule it depends on (parse, specialization, SIP, Magic Sets, IR, optimize): about 6.9 ms per evaluation on the voice-agent pack, repeated for every view on every commit. The engine now splits compilation from execution (IQLEngine::compile_program and execute_compiled_profiled). Snapshots keep their parsed persistent rules in a PersistentRules value that the next snapshot shares when the rules are unchanged. That value also holds the plans compiled against those rules, keyed by query program and optimizer passes. Standing queries (Handler::query_snapshot) look their plan up there; a rule change publishes new PersistentRules with no plans. Lookups are lock-free (ArcSwap, copy-on-write insert, bounded at 256 plans per rule set). Ad-hoc queries are unchanged. RuleCatalog::all_rules now lists rules in a deterministic order, so snapshots of the same rules have the same rule text (they differed by HashSet order). Sharing the parsed rules also saves re-parsing them on every commit.
…riables with The planner orders a rule's joins along a spanning tree of its join graph, then builds them left-deep in that order. It used a post-order traversal, which joins all of a node's children before the node itself. For a hub with two spokes that share nothing, such as shipment(O, S), promised(O, P), eta(S, D), it joined promised and eta first: a cross product of |promised| x |eta| rows. SIP generates exactly this shape for the voice-agent pack's late/on_time rules, so their semijoin rule grew quadratically with the number of sessions: 11 ms of a 37 ms evaluation at 100 sessions. The full speech query took 83 ms at 200 sessions and now takes 35 ms. The joins are now built in pre-order: each relation comes after its parent in the tree, so it always shares a variable with what is already joined.
…in bound constants
With one session-bound subscription per session on one knowledge graph
(?speech("s-1", ...), speech_owner("s-1", "sp-1", 1), and the same for
s-2 ...), every commit re-evaluated every session's view. Each evaluation
cost about as much as the unbound query, so CPU grew with sessions x commits
x tenant size. The voice-agent pack saturated a 32-core host between 25 and
50 sessions.
Such views now form a family. A query's string constants in positive atoms,
and its integer constants that no other positive atom repeats, are lifted to
parameters (?speech(_L0, ...), speech_owner(_L0, _L1, _L2)). The family
evaluates the lifted query once per revision (a round, lock-free: ArcSwap
plus a OnceCell per round). Each view takes the rows of its own constants,
projected to its own columns, and diffs them against its previous result
as before. Rows are matched as the engine matches the constant: strings
exactly, integers numerically (int, timestamp, float within tolerance).
Subscribers, deltas, revisions, authorization and the shared-view state
machine are unchanged.
A view evaluates its own query when it starts and after every rule change,
so its errors stay its own. It also does when a round fails, is truncated,
or holds more rows for it than max_result_rows. A family with one binding
does not share. A family whose round costs more than twice a view's own
evaluation and saves less than half of all views' own evaluations stops
sharing, and probes again after 256 own evaluations.
http.rate_limit.subscription_share_parameterized (default true) turns it
off.
On the voice-agent pack at 1 receipt/s per session (red-team harness):
25 sessions use 0.51 instead of 17.4 cores; 100 sessions deliver at
p50/p95/p99 40/64/81 ms on 1.6 cores (baseline: 896/1151/1151 ms,
saturated).
perf-gate sessions (make bench-sessions, scripts/bench-sessions.sh) rebuilds the hostile review's load harness as a repeatable benchmark. It loads the article's Appendix A pack verbatim, seeds N sessions, and gives each session a connection with its bound speech subscription. One executor connection subscribes to work. Every session commits a result-neutral receipt about once per second, while an adapter replaces random sessions' ETAs and times the commit and that session's delta. Each size runs on a fresh server. The run fails on a missing or stray delta or an error, and optionally on a write-to-delta p99 budget. --baseline-rev measures a baseline commit (cached with the gate's baselines) on the same sizes, and --mode shared-view measures the review's client-side routing comparison. The client gains API-key authentication: concurrent password logins from one address are throttled by design. Server specs gain extra environment for benchmarks, and running servers report their CPU time.
…onversion in sharing cost A family's latest round kept its snapshot until the next round replaced it, so an idle family pinned old data. The round now hands its snapshot to the evaluation that takes it. The cost that decides whether sharing pays also counts converting the rows and partitioning them, not only engine time: a lifted query with a wide result is no longer judged cheaper than it is.
With more distinct standing queries than plan slots (for example more than 256 sessions evaluating their own queries), clearing the whole map on every overflow left most lookups missing. Evicting one arbitrary plan keeps the rest.
…milies by parameter kinds
This was referenced Oct 4, 2026
jsam
force-pushed
the
fm/il-session-scale
branch
from
October 4, 2026 13:46
54af3fe to
0050f2a
Compare
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Keep fixing bugs and QCing the engine. Millisecond query and update latency must not regress; no new locks on hot paths. (Context: a hostile red-team of the voice-agent reference architecture measured the engine under that design, full method and data in ~/firstmate/data/voice-arch-novelty/report.md §6.2 and its appendices: with one session-bound standing subscription per session in one tenant graph, every commit re-evaluates every session's view, each evaluation costs about as much as the whole tenant (session bindings do not restrict work for non-recursive packs; Magic Sets only rewrites recursive relations), and ~6.9 ms of each re-evaluation is recompilation with no plan cache. Result: about 10 sessions fit a voice budget (write->delta p99 <= 100 ms); 50 sessions give p50 214 ms; 200 give 3.6 s. One shared unbound view with client-side routing cut CPU 16-27x and latency 2-24x.)
What Changed
src/protocol/subscription/parameterized.rs): standing queries that differ only in string or integer constants of positive atoms (for example one?speech("s-N", G, T)per session) are grouped into one family. The family evaluates the query once per commit, with those constants turned into parameters, and gives each view exactly the rows for its own constants. Each subscription still gets its own deltas, revisions, errors and access checks. A view evaluates its own query when it starts, after a rule change, and when the shared round fails or exceedsmax_result_rows.http.rate_limit.subscription_share_parameterizedsetting. Ashared_evaluationsmetric counts shared rounds.src/storage_engine/snapshot.rs,reevaluate.rs): a standing query is compiled once, and its plan is reused on every re-evaluation until the persistent rules change. When the cache is full, it evicts one plan instead of clearing all of them. A related fix injoin_planningstops the planner from joining a relation before the relation it shares variables with.perf-gate/src/sessions/,scripts/bench-sessions.sh, plus a Makefile target). Documents the setting and the sharing and plan-reuse behavior in the WebSocket and configuration guides and inconfig.example.toml. Adds unit tests andtests/standing_query_tests/parameterized.rs.Risk Assessment
Testing
I built the head release server and drove it live alongside the cached base b260bf7 server. In the adversarial bound-?reach scenario (40 sessions, 300-edge chain, 30 s, 2 alternating runs each), head write→delta p50/p95/p99/max was 48.2/67.8/74.6/80.3 and 41.0/61.7/68.3/80.7 ms, against base 44.0/68.0/72.7/80.0 and 42.6/64.9/70.2/77.7 ms. The 265–395 ms probe-induced max from the previous round is gone. There were no missing, foreign or error deltas, and subscription row counts matched one-off queries. In the paired voice-pack session benchmark, head stays well ahead of base: under-load p99 at 1/10/50/100/200 sessions was 25.7/36.3/46.5/65.6/134.9 ms on head versus 41.6/56.7/303.6/1171/4264 ms on base, with server CPU at 0.16–4.48 cores versus up to 23.8. Missing/stray deltas were 0/0 throughout. The targeted parameterized unit tests and the standing_query_tests integration suite pass. The host is shared (load average 2–21), so I alternated the base and head runs to control for host noise.
Evidence: Adversarial bound ?reach run log (base vs head, 2 runs each)
Evidence: Adversarial head run 1 results
Evidence: Adversarial head run 2 results
Evidence: Adversarial driver script
Evidence: Session-scale benchmark summary (head d1715dc vs base b260bf7)
Evidence: Under-load write→delta p50/p99
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 (3) ✅
src/protocol/subscription/parameterized.rs:132- Lifting turns every view's bound constants into free variables without checking whether the engine relied on those constants to limit work (Magic Sets on recursive relations, constant specialization, SIP). The cost guard (line 449) only reacts after a shared round has already run. A new family starts withsharing: AtomicBool::new(true)(line 531), and after every PROBE_AFTER=256 own evaluationsrecord_own(line 480) turns sharing back on. Each of those rounds evaluates the unbound query, and every view in the family waits on it (line 574). Concrete case: two sessions subscribe to?reach("a", Y)and?reach("b", Y)over a large edge graph. Each own query is a Magic-Sets-restricted millisecond evaluation. The first shared round and then every 256th probe compute the full transitive closure, which can take seconds and a lot of memory, or get truncated at max_result_rows only after the full computation, before either delta is delivered. This conflicts with the intent's "Millisecond query and update latency must not regress" for bound recursive standing queries, which previously stayed restricted. Suggested fix: don't lift constants on atoms whose closure reaches a recursive relation (the family can check this against the snapshot's rules when it is formed or when the rules change), and/or bound how long a probe round may run relative to own_cost_us.src/protocol/subscription/parameterized.rs:449- The sharing guard accepts a round costing up to max(2, bindings/2) times a view's own evaluation. With 200 bindings that is up to 100x own cost on a single evaluation that every view waits for. Without sharing, those 200 own evaluations run in parallel oncompute_permitsthreads (ncpu minus a reserve), so the last delta lands after about 200own/P. When P > 2 (any machine with 4 or more cores reserves 2), the guard therefore accepts rounds whose latency is up to P/2 times worse than unshared re-evaluation, and the first views' deltas wait for the whole round instead of about one own evaluation. The baseline is also inflated:own_cost_usis updated only on own evaluations, and in practice the only one before sharing starts is each view's first evaluation, which includes the plan-cache miss compile (about 7 ms per the intent's data), soworthis overstated. The intent says latency must not regress. Whether to trade per-delta latency for server CPU this way is a policy choice: consider bounding a round by the unshared parallel latency (for example ownbindings/compute_permits) rather than by CPU saving, and excluding compile time from own_cost_us.src/protocol/subscription/parameterized.rs:535- Families are keyed by (kg, lifted query text), but kinds are checked only after lookup (line 518). When?a("x", X)and?a(1, X)both lift to?a(_L0, X), the second creates a new family andfamilies.insertoverwrites the live first family's entry. Every later subscriber of the first kind then fails the kinds check and starts yet another family, overwriting again. Results stay correct, but alternating kinds keep fragmenting families into single-binding ones that never share. Fix: putshape.kindsin the map key.🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:
src/protocol/subscription/parameterized.rs:132- Lifting turns every view's bound constants into free variables without checking whether the engine relied on those constants to limit work (Magic Sets on recursive relations, constant specialization, SIP). The cost guard (line 449) only reacts after a shared round has already run. A new family starts withsharing: AtomicBool::new(true)(line 531), and after every PROBE_AFTER=256 own evaluationsrecord_own(line 480) turns sharing back on. Each of those rounds evaluates the unbound query, and every view in the family waits on it (line 574). Concrete case: two sessions subscribe to?reach("a", Y)and?reach("b", Y)over a large edge graph. Each own query is a Magic-Sets-restricted millisecond evaluation. The first shared round and then every 256th probe compute the full transitive closure, which can take seconds and a lot of memory, or get truncated at max_result_rows only after the full computation, before either delta is delivered. This conflicts with the intent's "Millisecond query and update latency must not regress" for bound recursive standing queries, which previously stayed restricted. Suggested fix: don't lift constants on atoms whose closure reaches a recursive relation (the family can check this against the snapshot's rules when it is formed or when the rules change), and/or bound how long a probe round may run relative to own_cost_us.src/protocol/subscription/parameterized.rs:449- The sharing guard accepts a round costing up to max(2, bindings/2) times a view's own evaluation. With 200 bindings that is up to 100x own cost on a single evaluation that every view waits for. Without sharing, those 200 own evaluations run in parallel oncompute_permitsthreads (ncpu minus a reserve), so the last delta lands after about 200own/P. When P > 2 (any machine with 4 or more cores reserves 2), the guard therefore accepts rounds whose latency is up to P/2 times worse than unshared re-evaluation, and the first views' deltas wait for the whole round instead of about one own evaluation. The baseline is also inflated:own_cost_usis updated only on own evaluations, and in practice the only one before sharing starts is each view's first evaluation, which includes the plan-cache miss compile (about 7 ms per the intent's data), soworthis overstated. The intent says latency must not regress. Whether to trade per-delta latency for server CPU this way is a policy choice: consider bounding a round by the unshared parallel latency (for example ownbindings/compute_permits) rather than by CPU saving, and excluding compile time from own_cost_us.src/protocol/subscription/parameterized.rs:535- Families are keyed by (kg, lifted query text), but kinds are checked only after lookup (line 518). When?a("x", X)and?a(1, X)both lift to?a(_L0, X), the second creates a new family andfamilies.insertoverwrites the live first family's entry. Every later subscriber of the first kind then fails the kinds check and starts yet another family, overwriting again. Results stay correct, but alternating kinds keep fragmenting families into single-binding ones that never share. Fix: putshape.kindsin the map key.src/protocol/subscription/parameterized.rs:513- The re-probe after a family stops sharing still fires far too often for large families, so the latency regression from round 1's lifted-round-unbounded finding is still reachable. Round 1's fix only changed the initial state (sharing now starts false) and left this cadence alone.own_since_stopcounts own evaluations summed over every view in the family, and every commit re-evaluates all of them. With 200 bindings, PROBE_AFTER=256 is crossed partway through the second commit after a stop.sharingthen flips to true, and every view of that commit that has not started yet waits on a full lifted round. Scenario: 200 sessions on?reach("s-N", Y), where Magic Sets restricts each own evaluation to about 1 ms but the unbound closure takes about 100 ms. The guard stops sharing after each probe, yet roughly every other commit pays a probe round that most views wait for. That breaks the required 'Millisecond query and update latency must not regress'. The guard only judges a round after it has run, so 'bounded by the same guard' does not bound how often a probe is paid for. Fix: count the probe interval in commits or rounds rather than summed own evaluations, for example per distinct revision orPROBE_AFTER * bindings. Optionally back off exponentially after repeated failed probes.src/protocol/subscription/reevaluate.rs:203- The recorded decision asked thatown_cost_uscome from plan-cached executions only (feedrecord_ownonly on a cache hit). The round-1 fix subtracts the timed compile stages fromtotal_usinstead, which leaves compile work in the measurement. On a miss,execute_with_rules_tuples_cached(src/storage_engine/snapshot.rs:484) setstotal_us = start.elapsed(). That still includescombine(parsing the query, closing over the rules, cloning them) and constant specialization, whosespec_usis never written to the breakdown (src/lib.rs:1484). Withtiming_mode = offthere is no breakdown at all, and run_query falls back tostarted.elapsed(), which includes the whole compile. The first own evaluation is always a miss, andrecord_ownstores it unaveraged and immediately enables sharing (parameterized.rs:505-515), so the first sharing decision is made against an inflated baseline. Later samples come only every 64 rounds with weight 1/8, so the inflation persists for hundreds of commits. Own plans can also keep missing once more than MAX_CACHED_PLANS=256 distinct session queries exist. Fix: carryplan_cached(compiled_nowalready exists) out throughquery_snapshot/QueryRows/Evaluatedand callrecord_ownonly on a hit, as the decision specified.🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:
src/protocol/subscription/parameterized.rs:132- Lifting turns every view's bound constants into free variables without checking whether the engine relied on those constants to limit work (Magic Sets on recursive relations, constant specialization, SIP). The cost guard (line 449) only reacts after a shared round has already run. A new family starts withsharing: AtomicBool::new(true)(line 531), and after every PROBE_AFTER=256 own evaluationsrecord_own(line 480) turns sharing back on. Each of those rounds evaluates the unbound query, and every view in the family waits on it (line 574). Concrete case: two sessions subscribe to?reach("a", Y)and?reach("b", Y)over a large edge graph. Each own query is a Magic-Sets-restricted millisecond evaluation. The first shared round and then every 256th probe compute the full transitive closure, which can take seconds and a lot of memory, or get truncated at max_result_rows only after the full computation, before either delta is delivered. This conflicts with the intent's "Millisecond query and update latency must not regress" for bound recursive standing queries, which previously stayed restricted. Suggested fix: don't lift constants on atoms whose closure reaches a recursive relation (the family can check this against the snapshot's rules when it is formed or when the rules change), and/or bound how long a probe round may run relative to own_cost_us.src/protocol/subscription/parameterized.rs:449- The sharing guard accepts a round costing up to max(2, bindings/2) times a view's own evaluation. With 200 bindings that is up to 100x own cost on a single evaluation that every view waits for. Without sharing, those 200 own evaluations run in parallel oncompute_permitsthreads (ncpu minus a reserve), so the last delta lands after about 200own/P. When P > 2 (any machine with 4 or more cores reserves 2), the guard therefore accepts rounds whose latency is up to P/2 times worse than unshared re-evaluation, and the first views' deltas wait for the whole round instead of about one own evaluation. The baseline is also inflated:own_cost_usis updated only on own evaluations, and in practice the only one before sharing starts is each view's first evaluation, which includes the plan-cache miss compile (about 7 ms per the intent's data), soworthis overstated. The intent says latency must not regress. Whether to trade per-delta latency for server CPU this way is a policy choice: consider bounding a round by the unshared parallel latency (for example ownbindings/compute_permits) rather than by CPU saving, and excluding compile time from own_cost_us.src/protocol/subscription/parameterized.rs:535- Families are keyed by (kg, lifted query text), but kinds are checked only after lookup (line 518). When?a("x", X)and?a(1, X)both lift to?a(_L0, X), the second creates a new family andfamilies.insertoverwrites the live first family's entry. Every later subscriber of the first kind then fails the kinds check and starts yet another family, overwriting again. Results stay correct, but alternating kinds keep fragmenting families into single-binding ones that never share. Fix: putshape.kindsin the map key.src/protocol/subscription/parameterized.rs:513- The re-probe after a family stops sharing still fires far too often for large families, so the latency regression from round 1's lifted-round-unbounded finding is still reachable. Round 1's fix only changed the initial state (sharing now starts false) and left this cadence alone.own_since_stopcounts own evaluations summed over every view in the family, and every commit re-evaluates all of them. With 200 bindings, PROBE_AFTER=256 is crossed partway through the second commit after a stop.sharingthen flips to true, and every view of that commit that has not started yet waits on a full lifted round. Scenario: 200 sessions on?reach("s-N", Y), where Magic Sets restricts each own evaluation to about 1 ms but the unbound closure takes about 100 ms. The guard stops sharing after each probe, yet roughly every other commit pays a probe round that most views wait for. That breaks the required 'Millisecond query and update latency must not regress'. The guard only judges a round after it has run, so 'bounded by the same guard' does not bound how often a probe is paid for. Fix: count the probe interval in commits or rounds rather than summed own evaluations, for example per distinct revision orPROBE_AFTER * bindings. Optionally back off exponentially after repeated failed probes.src/protocol/subscription/parameterized.rs:484- Round 2's fix (commit 10da403) went further than the instructions, which only coveredrecord_own. It also gates the sharing guard onran.plan_cached. Any round whose lifted plan had to compile now skips thekeeps_sharingcheck, so a slow round can never stop sharing. Concrete sequence: (1) A family starts sharing for the first time, or re-probes after a rule change. In both cases the lifted program is not in the plan cache. (2) That round runs the full unbound query, for example 100x a view's own cost, and is accepted without being judged. (3) The next commit runs another full round before the guard can react. So every first share and every rule change costs at least one extra slow round that every view waits for. (4) Worse, once more than MAX_CACHED_PLANS=256 programs exist for a rule set (other standing queries, samples, ad-hoc queries; arbitrary eviction at src/storage_engine/snapshot.rs:135), the lifted plan can be evicted between rounds. Every round then misses, none is ever judged, and sharing stays on no matter how slow the rounds are. Before this round, a miss round was still judged, using compile-subtracted timings. This conflicts with 'Millisecond query and update latency must not regress'. Fix: keep judging rounds that missed the cache. For example, on a miss usetiming.total_usminus the compile stages for the round's cost, since the user's decision only restricts own-cost samples. At minimum, let a miss round still callstop_sharingwhen it is slow even after its compile time is excluded. Sibling site: therounds/sample_dueincrement at line 494 is skipped on misses too, so samples also stop under eviction thrash.🔧 Fix applied.
✅ Re-checked - no issues remain.
🔧 **Test** - 2 issues found → auto-fixed ✅
cargo build --release --all-features --bin inputlayer-serverand the perf-gate build at 33f55b2bnode routing-check.mjsagainst the realinputlayer-server, default config (sharing on): 12 subscribers to?view(<s>, X, "a")with s1..s10 plus integers 7 and 8, 7 mixed writes, each subscriber's pushed result compared with its own one-off query after every writeSame routing check withINPUTLAYER_HTTP__RATE_LIMIT__SUBSCRIPTION_SHARE_PARAMETERIZED=falsenode recursive-latency.mjssmoke run (5 sessions on bound?reach(root, Y)over 5 chains): deltas correct, p50 4.1 msscripts/bench-sessions.sh --baseline-rev b260bf7a --sessions 1,10,50,100 --load-secs 10(timed runs in progress; only the base commit at 1 session had finished)🔧 Fix applied.
✅ Re-checked - no issues remain.
run-adv.sh+adv-reach-sessions.mjs(evidence dir): 40 sessions each subscribed to ?reach("r-i", Y) over a shared 300-edge chain (unbound closure 57,190 rows / ~333 ms; bound 301 rows / ~11 ms). Each session writes a private edge every second for 30 s and times write→delta, with a missing/foreign-delta check and a final row-count consistency check. Runs alternated base b260bf7a, head d1715dc, base, head.scripts/bench-sessions.sh --baseline-rev b260bf7a --sessions 1,10,50,100,200 --load-secs 20(perf-gate sessions, voice pack, 1 receipt/s per session)cargo test --release --lib -- subscription::parameterized(includes the new a_probe_too_slow_to_share_never_makes_a_view_wait)cargo test --release --test standing_query_tests✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
Branch history note: a devbox crash killed the previous pipeline run. The remote head at that point was
0050f2af559d6d4bf2a568f91f1ee2c736906cb6. The validated head2b11a771has the same 11 commits rebased onto newer main (range-diff: 10 identical, 1 differs only in the generatedgui/lib/docs-bundle.ts), plus two later review fixes.