Skip to content

feat(protocol): share evaluations across standing queries that differ only in bound constants, and cache their plans - #232

Closed
jsam wants to merge 11 commits into
mainfrom
fm/il-session-scale
Closed

jsam wants to merge 11 commits into
mainfrom
fm/il-session-scale

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Shared evaluation for bound-constant subscriptions (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 exceeds max_result_rows.
    • Sharing turns itself on and off per query shape. The family tries a shared round in the background, off the views' critical path, and starts sharing only if that round is no slower than the views' own evaluations run in parallel. It stops sharing when a round is slower, then tries again later, waiting longer after each failed try.
    • The feature is on by default and is controlled by the new http.rate_limit.subscription_share_parameterized setting. A shared_evaluations metric counts shared rounds.
  • Plan cache for re-evaluations (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 in join_planning stops the planner from joining a relation before the relation it shares variables with.
  • Benchmark and docs: adds a session-scale perf-gate benchmark on the voice-agent pack (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 in config.example.toml. Adds unit tests and tests/standing_query_tests/parameterized.rs.

Risk Assessment

⚠️ Medium: The latest fix round correctly judges every shared round, including cache misses with their compile stages subtracted, and counts every kept round toward sampling, as the user instructed; the medium rating comes from the size of the new latency-sensitive parameterized-sharing subsystem on the branch, not from any defect found here.

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.

  • Live validation: ✅ go - 4 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Adversarial: 40 sessions on bound recursive ?reach("r-i",Y), whose unbound closure is large (57k rows, ~333 ms), get deltas with no probe-induced tail: head max/p99 matches base ✅ pass live adv-reach-40-sessions.log: head p99/max 74.6/80.3 and 68.3/80.7 ms vs base 72.7/80.0 and 70.2/77.7 ms; missing 0, foreign 0
Adversarial correctness: each session's deltas carry only its own root's rows, and accumulated rows equal a one-off query ✅ pass live adv-*.json: foreign_or_error_pushes 0, final_rowcount_mismatches_of_5 0, missing 0
Session-scale write→delta latency (voice pack, 1–200 sessions) does not regress against base; sharing still engages with detached probes ✅ pass live bench-sessions-head-d1715dc-vs-b260bf7a-summary.md: head under-load p99 25.7/36.3/46.5/65.6/134.9 ms vs base 41.6/56.7/303.6/1171/4264 ms; CPU 1.03 cores at 50 sessions vs 23.67
Session-scale deltas complete: no missing or stray deltas at any size ✅ pass live bench summary Missing/stray column 0/0 for every arm
A probe too slow to share never makes a view's refresh wait, and sharing stays off ⏸️ untested no The prior payload covered this only with a unit test (a_probe_too_slow_to_share_never_makes_a_view_wait passed) and did not establish a live result for this internal family-state behavior. Its live en…
Evidence: Adversarial bound ?reach run log (base vs head, 2 runs each)
 13:36:31 up 1 day,  3:53,  2 users,  load average: 2.03, 4.41, 6.64
base-b260bf7a-r1: 40 sessions; unbound 57190 rows 333.3 ms; bound 301 rows 10.6 ms; initial sub rows 301
{"label":"base-b260bf7a-r1","N":40,"CHAIN":300,"unbound_rows":57190,"unbound_query_ms":333.3,"bound_rows":301,"bound_query_ms":10.6,"commit_ms":{"n":1200,"p50":10.8,"p95":18.6,"p99":20.7,"max":26.9},"write_to_delta_ms":{"n":1200,"p50":44,"p95":68,"p99":72.7,"max":80},"missing":0,"foreign_or_error_pushes":0,"write_errors":0,"final_rowcount_mismatches_of_5":0}
base-b260bf7a-r1 server cpu seconds:      604
 13:37:06 up 1 day,  3:54,  2 users,  load average: 11.33, 6.39, 7.22
head-d1715dc-r1: 40 sessions; unbound 57190 rows 332.5 ms; bound 301 rows 11.0 ms; initial sub rows 301
{"label":"head-d1715dc-r1","N":40,"CHAIN":300,"unbound_rows":57190,"unbound_query_ms":332.5,"bound_rows":301,"bound_query_ms":11,"commit_ms":{"n":1201,"p50":13.3,"p95":17.9,"p99":21.9,"max":28.4},"write_to_delta_ms":{"n":1201,"p50":48.2,"p95":67.8,"p99":74.6,"max":80.3},"missing":0,"foreign_or_error_pushes":0,"write_errors":0,"final_rowcount_mismatches_of_5":0}
head-d1715dc-r1 server cpu seconds:      559
 13:37:40 up 1 day,  3:55,  2 users,  load average: 16.30, 8.12, 7.76
base-b260bf7a-r2: 40 sessions; unbound 57190 rows 332.9 ms; bound 301 rows 10.7 ms; initial sub rows 301
{"label":"base-b260bf7a-r2","N":40,"CHAIN":300,"unbound_rows":57190,"unbound_query_ms":332.9,"bound_rows":301,"bound_query_ms":10.7,"commit_ms":{"n":1200,"p50":11.8,"p95":19.4,"p99":22.3,"max":29.3},"write_to_delta_ms":{"n":1200,"p50":42.6,"p95":64.9,"p99":70.2,"max":77.7},"missing":0,"foreign_or_error_pushes":0,"write_errors":0,"final_rowcount_mismatches_of_5":0}
base-b260bf7a-r2 server cpu seconds:      610
 13:38:15 up 1 day,  3:55,  2 users,  load average: 17.62, 9.24, 8.15
head-d1715dc-r2: 40 sessions; unbound 57190 rows 334.6 ms; bound 301 rows 11.3 ms; initial sub rows 301
{"label":"head-d1715dc-r2","N":40,"CHAIN":300,"unbound_rows":57190,"unbound_query_ms":334.6,"bound_rows":301,"bound_query_ms":11.3,"commit_ms":{"n":1200,"p50":11.2,"p95":18,"p99":21.6,"max":29.4},"write_to_delta_ms":{"n":1200,"p50":41,"p95":61.7,"p99":68.3,"max":80.7},"missing":0,"foreign_or_error_pushes":0,"write_errors":0,"final_rowcount_mismatches_of_5":0}
head-d1715dc-r2 server cpu seconds:      618
 13:38:50 up 1 day,  3:56,  2 users,  load average: 21.06, 10.97, 8.76
Evidence: Adversarial head run 1 results
{
 "label": "head-d1715dc-r1",
 "N": 40,
 "CHAIN": 300,
 "unbound_rows": 57190,
 "unbound_query_ms": 332.5,
 "bound_rows": 301,
 "bound_query_ms": 11,
 "commit_ms": {
  "n": 1201,
  "p50": 13.3,
  "p95": 17.9,
  "p99": 21.9,
  "max": 28.4
 },
 "write_to_delta_ms": {
  "n": 1201,
  "p50": 48.2,
  "p95": 67.8,
  "p99": 74.6,
  "max": 80.3
 },
 "missing": 0,
 "foreign_or_error_pushes": 0,
 "write_errors": 0,
 "final_rowcount_mismatches_of_5": 0
}
Evidence: Adversarial head run 2 results
{
 "label": "head-d1715dc-r2",
 "N": 40,
 "CHAIN": 300,
 "unbound_rows": 57190,
 "unbound_query_ms": 334.6,
 "bound_rows": 301,
 "bound_query_ms": 11.3,
 "commit_ms": {
  "n": 1200,
  "p50": 11.2,
  "p95": 18,
  "p99": 21.6,
  "max": 29.4
 },
 "write_to_delta_ms": {
  "n": 1200,
  "p50": 41,
  "p95": 61.7,
  "p99": 68.3,
  "max": 80.7
 },
 "missing": 0,
 "foreign_or_error_pushes": 0,
 "write_errors": 0,
 "final_rowcount_mismatches_of_5": 0
}
Evidence: Adversarial driver script
// Adversarial: N sessions each subscribe ?reach("r-i", Y) where r-i -> c0 -> ... -> cCHAIN
// (a shared chain). Bound evaluation is small (Magic Sets); the unbound closure is large.
// Each session writes a private edge r-i -> p-i-k once per second and times write -> its own delta.
// Usage: node adv-reach-sessions.mjs <port> <password> <label> <N> <CHAIN> <secs> <outjson>
const [port, password, label, NS, CS, SS, out] = process.argv.slice(2);
const N = +NS, CHAIN = +CS, SECS = +SS;
const KG = "adv"; const url = `ws://127.0.0.1:${port}/ws`;
const REPLY = ["result","result_start","result_chunk","result_end","error","authenticated","auth_error"];
function open(kg) { return new Promise((resolve, reject) => {
  const ws = new WebSocket(kg ? `${url}?kg=${kg}` : url);
  const c = { ws, queue: [], waiters: [], pushes: [], onpush: null };
  ws.onmessage = (ev) => { const at = performance.now(); const m = JSON.parse(ev.data);
    if (m.type === "subscription_delta" || m.type === "subscription_error") { m.at = at; c.pushes.push(m); if (c.onpush) c.onpush(m); return; }
    if (!REPLY.includes(m.type)) return; const w = c.waiters.shift(); if (w) w(m); else c.queue.push(m); };
  ws.onerror = reject; ws.onopen = () => resolve(c); }); }
const next = (c) => new Promise((res) => { const m = c.queue.shift(); if (m) res(m); else c.waiters.push(res); });
async function exec(c, program) { c.ws.send(JSON.stringify({ type: "execute", program })); let r = await next(c);
  if (r.type === "result_start") { const rows = []; for (;;) { const m = await next(c); if (m.rows) rows.push(...m.rows); if (m.type === "result_end") return { type: "result", rows }; } } return r; }
async function login(kg) { const c = await open(kg); c.ws.send(JSON.stringify({ type: "login", username: "admin", password }));
  const r = await next(c); if (r.type !== "authenticated") throw new Error(JSON.stringify(r)); return c; }
const ok = (r, p) => { if (r.type !== "result") throw new Error(p.slice(0, 80) + " -> " + JSON.stringify(r).slice(0, 300)); return r; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const pct = (a, f) => a.length ? +a[Math.min(a.length - 1, Math.floor(f * a.length))].toFixed(1) : null;
const stats = (a) => { a = [...a].sort((x, y) => x - y); return { n: a.length, p50: pct(a, .5), p95: pct(a, .95), p99: pct(a, .99), max: a.length ? +a[a.length-1].toFixed(1) : null }; };

const admin = await login(); await exec(admin, `.kg create ${KG}`);
const w = await login(KG);
for (const p of ["+reach(X, Y) <- edge(X, Y)", "+reach(X, Z) <- reach(X, Y), edge(Y, Z)"]) ok(await exec(w, p), p);
const facts = []; for (let i = 0; i < CHAIN; i++) facts.push(`("c${i}", "c${i + 1}")`);
for (let s = 0; s < N; s++) facts.push(`("r-${s}", "c0")`);
ok(await exec(w, `+edge[${facts.join(", ")}]`), "edges");
let t = performance.now(); const ub = ok(await exec(w, "?reach(X, Y)"), "unbound"); const ubMs = performance.now() - t;
t = performance.now(); const bd = ok(await exec(w, `?reach("r-0", Y)`), "bound"); const bdMs = performance.now() - t;
const subs = [];
for (let s = 0; s < N; s++) { const c = await login(KG); const r = ok(await exec(c, `.subscribe sub ?reach("r-${s}", Y)`), "subscribe");
  subs.push({ c, root: `r-${s}`, n: (r.rows || []).length, s }); }
console.log(`${label}: ${N} sessions; unbound ${ub.rows.length} rows ${ubMs.toFixed(1)} ms; bound ${bd.rows.length} rows ${bdMs.toFixed(1)} ms; initial sub rows ${subs[0].n}`);
const lat = [], commit = []; let missing = 0, bad = 0, errs = 0;
const deadline = performance.now() + SECS * 1000;
await Promise.all(subs.map(async (sub) => {
  await sleep(Math.random() * 1000); let k = 0;
  while (performance.now() < deadline) {
    const start = performance.now(); const target = `p-${sub.s}-${k++}`;
    const got = new Promise((res) => { sub.c.onpush = (m) => { if ((m.inserted || []).some((r) => r[1] === target)) res(m); }; });
    const r = await exec(sub.c, `+edge("${sub.root}", "${target}")`); commit.push(performance.now() - start);
    if (r.type !== "result") { errs++; }
    const m = await Promise.race([got, sleep(15000).then(() => null)]); sub.c.onpush = null;
    if (!m) { missing++; } else lat.push(m.at - start);
    const wait = 1000 - (performance.now() - start); if (wait > 0) await sleep(wait);
  }
}));
await sleep(500);
for (const sub of subs) for (const p of sub.c.pushes) {
  if (p.type !== "subscription_delta") { bad++; continue; }
  for (const r of [...(p.inserted || []), ...(p.retracted || [])]) if (r[0] !== sub.root) bad++;
}
// Final consistency: each session's subscription rows (initial + deltas) equal its one-off answer.
let mismatched = 0;
for (const sub of subs.slice(0, 5)) { const q = ok(await exec(sub.c, `?reach("${sub.root}", Y)`), "check");
  const ins = sub.c.pushes.reduce((a, p) => a + (p.inserted || []).length - (p.retracted || []).length, 0);
  if (q.rows.length !== sub.n + ins) mismatched++; }
const res = { label, N, CHAIN, unbound_rows: ub.rows.length, unbound_query_ms: +ubMs.toFixed(1), bound_rows: bd.rows.length, bound_query_ms: +bdMs.toFixed(1),
  commit_ms: stats(commit), write_to_delta_ms: stats(lat), missing, foreign_or_error_pushes: bad, write_errors: errs, final_rowcount_mismatches_of_5: mismatched };
console.log(JSON.stringify(res)); if (out) (await import("fs")).writeFileSync(out, JSON.stringify(res, null, 1));
process.exit(0);
Evidence: Session-scale benchmark summary (head d1715dc vs base b260bf7)
# Session scale (PerSession)

- baseline: b260bf7a79770a04399dfbe2ed350e4993a3b47d (~/.no-mistakes/worktrees/9eea230c3aae/01M43D7SN7K9F0HATBNA3992QD/target/perf-gate/servers/b260bf7a79770a04399dfbe2ed350e4993a3b47d-8dc912acd5bf/inputlayer-server)
- candidate: d1715dcc3ff4531f3fca38a1a8ce15bae1b3eaa5 (~/.no-mistakes/worktrees/9eea230c3aae/01M43D7SN7K9F0HATBNA3992QD/target/bench-sessions/runs/20261004T133857Z/inputlayer-server)
- load: 1 receipt(s)/s per session for 20 s after a 2 s warm-up; host computeinstance-e00sx5ndp6kwb7pqfv (32 CPUs), load average 19.54 10.82 8.73 2/1952 3761144 -> 18.59 14.82 10.80 2/1919 3863098

| Arm | Sessions | Receipts/s | Server CPU (cores) | No load: write->delta p50 / p99 | Under load: write->delta p50 / p95 / p99 | Receipt commit p50 / p99 | Missing / stray |
|---|---|---|---|---|---|---|---|
| baseline | 1 | 1.0 | 0.22 | 23.4 / 27.5 ms | 24.7 / 28.6 / 41.6 ms | 3.4 / 11.0 ms | 0 / 0 |
| candidate | 1 | 1.0 | 0.16 | 15.9 / 20.6 ms | 17.4 / 22.5 / 25.7 ms | 2.9 / 5.4 ms | 0 / 0 |
| baseline | 10 | 9.9 | 3.68 | 26.1 / 34.8 ms | 28.9 / 45.2 / 56.7 ms | 3.4 / 6.6 ms | 0 / 0 |
| candidate | 10 | 9.9 | 2.26 | 15.9 / 19.4 ms | 20.0 / 30.1 / 36.3 ms | 3.0 / 10.3 ms | 0 / 0 |
| baseline | 50 | 47.0 | 23.67 | 76.6 / 123.8 ms | 212.7 / 297.0 / 303.6 ms | 61.2 / 79.4 ms | 0 / 0 |
| candidate | 50 | 49.7 | 1.03 | 20.3 / 30.8 ms | 26.2 / 41.0 / 46.5 ms | 3.3 / 10.8 ms | 0 / 0 |
| baseline | 100 | 77.6 | 23.53 | 280.2 / 520.1 ms | 864.8 / 1171.0 / 1171.0 ms | 286.1 / 362.5 ms | 0 / 0 |
| candidate | 100 | 99.4 | 1.79 | 24.7 / 116.3 ms | 39.4 / 57.7 / 65.6 ms | 3.5 / 13.0 ms | 0 / 0 |
| baseline | 200 | 78.2 | 23.82 | 1372.2 / 2839.3 ms | 4015.0 / 4263.7 / 4263.7 ms | 1535.1 / 1606.9 ms | 0 / 0 |
| candidate | 200 | 197.8 | 4.48 | 34.2 / 260.5 ms | 78.7 / 118.4 / 134.9 ms | 4.7 / 21.6 ms | 0 / 0 |
Evidence: Under-load write→delta p50/p99
sessions base p50/p99 ms head p50/p99 ms cpu base→head
1 24.7/41.6 17.4/25.7 0.22→0.16
10 28.9/56.7 20.0/36.3 3.68→2.26
50 212.7/303.6 26.2/46.5 23.67→1.03
100 864.8/1171.0 39.4/65.6 23.53→1.79
200 4015.0/4263.7 78.7/134.9 23.82→4.48
adversarial ?reach 40 sessions max: base 80.0/77.7, head 80.3/80.7 ms (previous head 264.9/394.7)
- Outcome: 🔧 2 issues found → auto-fixed ✅ across 2 runs (36m29s)

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 with sharing: AtomicBool::new(true) (line 531), and after every PROBE_AFTER=256 own evaluations record_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(&#34;a&#34;, Y) and ?reach(&#34;b&#34;, 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 on compute_permits threads (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_us is 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), so worth is 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(&#34;x&#34;, X) and ?a(1, X) both lift to ?a(_L0, X), the second creates a new family and families.insert overwrites 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: put shape.kinds in 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 with sharing: AtomicBool::new(true) (line 531), and after every PROBE_AFTER=256 own evaluations record_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(&#34;a&#34;, Y) and ?reach(&#34;b&#34;, 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 on compute_permits threads (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_us is 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), so worth is 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(&#34;x&#34;, X) and ?a(1, X) both lift to ?a(_L0, X), the second creates a new family and families.insert overwrites 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: put shape.kinds in 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_stop counts 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. sharing then 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(&#34;s-N&#34;, 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 or PROBE_AFTER * bindings. Optionally back off exponentially after repeated failed probes.
  • ⚠️ src/protocol/subscription/reevaluate.rs:203 - The recorded decision asked that own_cost_us come from plan-cached executions only (feed record_own only on a cache hit). The round-1 fix subtracts the timed compile stages from total_us instead, which leaves compile work in the measurement. On a miss, execute_with_rules_tuples_cached (src/storage_engine/snapshot.rs:484) sets total_us = start.elapsed(). That still includes combine (parsing the query, closing over the rules, cloning them) and constant specialization, whose spec_us is never written to the breakdown (src/lib.rs:1484). With timing_mode = off there is no breakdown at all, and run_query falls back to started.elapsed(), which includes the whole compile. The first own evaluation is always a miss, and record_own stores 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: carry plan_cached (compiled_now already exists) out through query_snapshot/QueryRows/Evaluated and call record_own only 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 with sharing: AtomicBool::new(true) (line 531), and after every PROBE_AFTER=256 own evaluations record_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(&#34;a&#34;, Y) and ?reach(&#34;b&#34;, 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 on compute_permits threads (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_us is 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), so worth is 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(&#34;x&#34;, X) and ?a(1, X) both lift to ?a(_L0, X), the second creates a new family and families.insert overwrites 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: put shape.kinds in 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_stop counts 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. sharing then 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(&#34;s-N&#34;, 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 or PROBE_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 covered record_own. It also gates the sharing guard on ran.plan_cached. Any round whose lifted plan had to compile now skips the keeps_sharing check, 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 use timing.total_us minus 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 call stop_sharing when it is slow even after its compile time is excluded. Sibling site: the rounds/sample_due increment 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 ✅
  • ⚠️ There is no live latency comparison against the base commit yet. The paired session-scale benchmark (scripts/bench-sessions.sh --baseline-rev b260bf7 --sessions 1,10,50,100 --load-secs 10) has started its timed runs but had only finished the base commit at 1 session when this step had to report (write->delta p50/p99 25.4/37.0 ms under load). The 'millisecond query and update latency must not regress' requirement is unconfirmed until its summary (target/bench-sessions/latest/summary.md, log in the evidence directory) is reviewed or the step is re-run.
  • ⚠️ live validation verdict: inconclusive (3 of 5 scenarios were driven live against the product); untested: Session-scale write->delta latency (1-100 sessions, voice pack) does not regress against the base commit, Adversarial: many sessions on bound recursive ?reach(root,Y), whose unbound closure is large, keep millisecond deltas (the cost guard stops sharing)
  • Live validation: ⚠️ inconclusive - 3 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Sessions subscribing to the same rule with different bound constants each receive exactly their own rows across inserts, moves and retractions (sharing on) ✅ pass live routing-sharing-on.log: 12 subscribers, 7 writes, 0 mismatches and 0 foreign rows
Adversarial: string and integer bindings of the same query shape (?view("s1",..) vs ?view(7,..)) never mix rows ✅ pass live routing-sharing-on.log: final results for ?view(7,..) and ?view(8,..) correct, no FOREIGN ROW lines
Operator switches sharing off with subscription_share_parameterized=false and results stay correct ✅ pass live routing-sharing-off.log: PASS, 0 failures
Session-scale write->delta latency (1-100 sessions, voice pack) does not regress against the base commit ⏸️ untested no The paired benchmark was started live against both binaries, but only the base commit at 1 session had finished when this step had to return its result. Re-run the step or read target/bench-sessions/l…
Adversarial: many sessions on bound recursive ?reach(root,Y), whose unbound closure is large, keep millisecond deltas (the cost guard stops sharing) ⏸️ untested no Only a small smoke run was done. The full-scale on/off comparison was deferred so it would not compete for CPU with the in-progress benchmark, and the step had to report before it could run.
  • cargo build --release --all-features --bin inputlayer-server and the perf-gate build at 33f55b2b
  • node routing-check.mjs against the real inputlayer-server, default config (sharing on): 12 subscribers to ?view(&lt;s&gt;, X, &#34;a&#34;) 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 write
  • Same routing check with INPUTLAYER_HTTP__RATE_LIMIT__SUBSCRIPTION_SHARE_PARAMETERIZED=false
  • node recursive-latency.mjs smoke run (5 sessions on bound ?reach(root, Y) over 5 chains): deltas correct, p50 4.1 ms
  • scripts/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.

  • Live validation: ✅ go - 4 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Adversarial: 40 sessions on bound recursive ?reach("r-i",Y), whose unbound closure is large (57k rows, ~333 ms), get deltas with no probe-induced tail: head max/p99 matches base ✅ pass live adv-reach-40-sessions.log: head p99/max 74.6/80.3 and 68.3/80.7 ms vs base 72.7/80.0 and 70.2/77.7 ms; missing 0, foreign 0
Adversarial correctness: each session's deltas carry only its own root's rows, and accumulated rows equal a one-off query ✅ pass live adv-*.json: foreign_or_error_pushes 0, final_rowcount_mismatches_of_5 0, missing 0
Session-scale write→delta latency (voice pack, 1–200 sessions) does not regress against base; sharing still engages with detached probes ✅ pass live bench-sessions-head-d1715dc-vs-b260bf7a-summary.md: head under-load p99 25.7/36.3/46.5/65.6/134.9 ms vs base 41.6/56.7/303.6/1171/4264 ms; CPU 1.03 cores at 50 sessions vs 23.67
Session-scale deltas complete: no missing or stray deltas at any size ✅ pass live bench summary Missing/stray column 0/0 for every arm
A probe too slow to share never makes a view's refresh wait, and sharing stays off ⏸️ untested no The prior payload covered this only with a unit test (a_probe_too_slow_to_share_never_makes_a_view_wait passed) and did not establish a live result for this internal family-state behavior. Its live en…
  • 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 head 2b11a771 has the same 11 commits rebased onto newer main (range-diff: 10 identical, 1 differs only in the generated gui/lib/docs-bundle.ts), plus two later review fixes.

jsam added 9 commits October 4, 2026 12:12
…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.
@jsam
jsam force-pushed the fm/il-session-scale branch from 54af3fe to 0050f2a Compare October 4, 2026 13:46
@jsam jsam changed the title perf(subscriptions): standing-query cost that scales with the change, not the sessions feat(protocol): share evaluations across standing queries that differ only in bound constants, and cache their plans Oct 4, 2026
@jsam

jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #254 (same validated work plus the latency-guard review fixes; old head 0050f2a recorded there).

@jsam jsam closed this Oct 4, 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