Repository navigation
Conversation
Identical standing queries (same knowledge graph, same query text) now share one view: one evaluation per relevant commit and one result in memory, across connections and users. - views: shared-view state machine keyed by the full (KG, query) key, keeping the dependency and pending-change machine per view, with an optional bounded coalescing window (http.rate_limit.subscription_coalesce_ms, default 0, at most 100 ms). - hub: one worker task owns the views, its own notification receiver and the evaluations; connections attach and detach over a channel. - publication: lock-free hand-off (ArcSwap cell plus one queued wake-up per subscriber), so a slow connection coalesces instead of queueing. - subscriber: per-subscriber seq and last delivered result; read access is checked before a delta is built, so a denied push keeps the last result and leaves no seq gap. - result_set: rows keyed by structural hash plus exact equality instead of canonical JSON strings. - Read-access cache is keyed by role as well as ACL generation, so a demotion is seen at once. Evaluation no longer re-authorizes per subscriber; authorization happens at subscribe and before each push.
… repeated failures
…triggered retries
This was referenced Oct 4, 2026
jsam
added a commit
that referenced
this pull request
Oct 4, 2026
…ry request (#249) The release-checkpoint performance gate fails on main: cheap_query serial p50 is 1.110x the approved baseline e732cd5 on the 32-vCPU runner (115 -> 126 us). Concurrent latency and throughput did not move. Bisected to #214 (per-connection request pipelining), made worse by #222. Cause: the pipeline runs requests in a `FuturesUnordered`. Its `poll_next` wakes its own task and returns `Pending` after any poll that polled every future in the set. With one request in flight, which is every serial client, that happens on each request's first poll. The connection task wakes itself, tokio schedules that as a yield, and a yield wakes an idle worker to take the task. Each request therefore costs one more context switch (4.0 -> 5.0 per query) and a hand-off of the connection task to another core. #222 added no work per query but made the hand-off dearer: user cycles per query rose 15% at the same instruction count. Fix: keep one never-woken future in the set. After the pipeline's first poll it is never polled again, so no poll reaches every future and the forced yield no longer fires. The yield after futures that wake themselves still applies. Pipelining, ordering, deadlines, cancellation and the credential fence are unchanged. Nothing is added per request: no lock, atomic or allocation. Each connection gets one more set node. Bisect (cheap_query, `?edge(1, Y)`, serial). Server counters per query from perf stat. Ratios are against e732cd5, from an interleaved probe with one pinned server per commit, 40 rounds: | commit | PR | user instr/query | ctx switches/query | p50 ratio | |---|---|---|---|---| | e732cd5 | baseline | 1.282M | 4.0 | 1.000 | | d75ce6e | #208 atomic programs | 1.279M | 4.0 | 0.993 | | 4578fe6 | #215 notification stream | 1.271M | 4.0 | 0.991 | | 71a4d24 | #216 online backup | 1.275M | 4.0 | 1.016 | | 2f621c5 | #200 first-update latency | 1.272M | 4.0 | 1.009 | | e96df5c | #207 | 1.279M | 4.0 | 0.998 | | 4dd8fe9 | #209 shard index | 1.272M | 4.0 | 0.987 | | 277c7f0 | #206 | 1.274M | 4.0 | 1.008 | | 3554c98 | #214 pipelining, deadlines | 1.296M | 5.0 | 1.020 | | 3492a59 | #213 | 1.294M | 5.0 | 1.030 | | 2ba9a5a | #212 provenance | 1.297M | 5.0 | 1.029 | | 9ed61b4 | #204 key expiry | 1.297M | 5.0 | 1.027 | | be35ee3 | #221 | 1.297M | 5.0 | 1.013 | | c98a590 | #222 bounded streaming | 1.298M | 5.0 | 1.047 | | a6fd628 | #219 wildcard fix | 1.297M | 5.0 | 1.061 | | b7f7ad6 | #220 WAL durability | 1.300M | 5.0 | 1.056 | | de54807 | #223 shared standing eval | 1.299M | 5.0 | 1.071 | | 55ec34d | #225 checkpoint-1 fixes | 1.300M | 5.0 | 1.045 | | 23712b8 | main | 1.300M | 5.0 | 1.064 | | this change | | 1.300M | 4.0 | 1.033 | Confirming A/Bs with the gate's own fixture (fresh pinned servers each round, 40 rounds, ratio [95% interval]): - #214 vs its parent 277c7f0: 1.039 [1.027..1.049] - #222 vs its parent be35ee3: 1.025 [1.006..1.044] - A/A 3554c98: 1.010 [1.002..1.025] - e732cd5 -> main: 1.048 [1.028..1.066] - main -> this change: 0.978 [0.958..0.989] - e732cd5 -> this change: 1.021 [1.009..1.044], PASS (budget 1.05) User cycles per query (perf stat, same instruction count): e732cd5 958K, be35ee3 1053K, c98a590 1206K, this change 1006K. Full gate, all fixtures, 20 rounds, servers pinned to CPUs 2-15: - main -> this change: no metric worse. Every required metric is PASS, or INCONCLUSIVE with an interval that includes 1.0. cheap_query p50 0.979 [0.953..1.007]. - e732cd5 -> this change: cheap_query p50 1.029 [1.019..1.072], INCONCLUSIVE on a busy host (load average up to 22); bound_query p50 1.022, PASS. The insert and delta metrics improved since the baseline. The shared host is about 4x slower per query than the CI runner, so the same hand-off is a smaller share of each query here than there. The remaining ~1.7% of user instructions per query since #214 is the pipeline's own per-request bookkeeping: the boxed request future (one 4-8 KiB allocation), its set node, the request control and the in-flight maps.
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
Implement this work item fully and correctly. Always follow clean code principles and clean architecture. Millisecond query and update latency must not regress; no new locks on hot paths. Keep documentation current with the change.
The work item is E01, shared authorized standing evaluation, part of #133 (standing queries: share evaluations and debounce; the DD-backed deltas part of that issue is out of scope). Identical standing-query subscriptions must share one evaluation, owned by a worker rather than guarded by a global per-delta map mutex, keyed by the canonical query, knowledge graph, rules and visibility (authorization) state, with collision-safe keys (hash plus full equality) for both queries and result tuples. Keep the existing per-subscription dependency filtering and pending-change state machine (at most one evaluation in flight; changes seen meanwhile, including rule changes during a refresh, trigger a correct follow-up). Any coalescing delay must be bounded by an approved latency budget. Duplicate subscribers must cost less work (evaluation count and memory) than today, and a single subscriber must not get slower. Sharing must never leak rows across tenants or authorization scopes: two auth scopes, revoked access, unsubscribe and reuse of a subscription id, unrelated writes and hash collisions must all behave correctly, with one shared refresh where that is valid.
What Changed
registrywith shared views. Identical subscriptions (same trimmed query text, same knowledge graph) now share one evaluation and one in-memory result across connections and users. Views are owned by a singleSubscriptionHubworker task (hub,views,publication). Each view is identified by a collision-safe key, and result rows are kept in a hash-plus-equalityResultSet. Each view still filters changes by dependency and runs at most one evaluation at a time, with one follow-up for changes that land meanwhile. A rule change retires the affected view so later.subscribes get a fresh evaluation. A new subscriber waits for any refresh that is already running or due. When the last evaluation failed, a new subscriber triggers a single retry. A newviewsmetric counts shared views.Subscriberkeeps its own read-access check,seqnumbering and last delivered result. Access is checked when it subscribes, before its snapshot is sent and before every push. Revoked access now sends asubscription_errorinstead of asubscription_reset, and the subscription keeps its last result, soseqhas no gap once access returns. A slow reader gets one combined delta instead of a queue.notifications_missedno longer affects subscription deltas.http.rate_limit.subscription_coalesce_ms: a coalescing window, default 0 and capped at 100 ms. Updated the config example, the configuration and WebSocket API guides, the AsyncAPI spec andNoticeCodedocs to match. Added view, subscriber and failure unit tests, a standing-query sharing test suite (tests/standing_query_tests/sharing.rs), updates to the differential oracle, and benchmarks for duplicate subscribers.Risk Assessment
Testing
I rebuilt the server at HEAD and ran a Node WebSocket driver against throwaway server instances (temp data dirs, env-var config, local ports) across five phases. Covered: sharing between auth scopes, isolation by KG and by relation, revocation, unsubscribing and reusing a subscription id, and rule changes. Also covered: result-cap errors on each commit, retries that don't send existing subscribers a duplicate error (including 3 concurrent waiters over 5 rounds), read-your-writes on the snapshot for an acknowledged write (200/200), and a 100 ms coalescing window (10 commits collapsed into 1 delta). Every live assertion passed. I could not observe the shared evaluation count or hash collisions from outside the server, so that scenario is untested live. The targeted standing_query integration tests and subscription unit tests cover it, and they all passed. The first read-your-writes run failed after 33 iterations. It had hit the server's default per-connection WebSocket limit of 100 messages per second, which was a problem with my test setup. I raised the limit in the throwaway config and reran: 200/200 passed. There is no UI, so the evidence is CLI and WebSocket transcripts.
Evidence: Live WS driver (round 3)
Evidence: Server launcher for disposable instances
Evidence: Sharing/auth/unsubscribe/rule-change transcript
Evidence: Result cap / retry / recovery transcript
Evidence: Read-your-writes transcript (200 iterations)
~/.no-mistakes/evidence/01M41XN8JSRPNN3WGVSDF886ZF/live_ws_transcript_concurrent_waiters.round3.txt)Evidence: Coalescing window transcript
~/.no-mistakes/evidence/01M41XN8JSRPNN3WGVSDF886ZF/standing_query_tests.round3.log)~/.no-mistakes/evidence/01M41XN8JSRPNN3WGVSDF886ZF/subscription_unit_tests.round3.log)Pipeline
Updates from git push no-mistakes
... (3 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)
🔧 **Review** - 4 issues found → auto-fixed (3) ✅
🔧 Fix applied.
4 warnings still open:
src/protocol/subscription/views.rs:195- After a view's latest refresh fails, every new.subscribeto that query is rejected with the stale error, and nothing triggers a retry. The view re-evaluates only when a commit touches its dependencies. Transient failures are real here:request_control(None)appliesquery_timeout_ms, and that deadline also covers the wait for the query semaphore. So one timeout under load blocks the query for every new subscriber, across users and connections, until an unrelated relevant write arrives. Before this change, each.subscriberan a fresh evaluation and recovered by itself. The docs describe this rejection as intended (websocket-api.mdx), so this needs your decision. Proposed fix: when someone attaches to a Failed view that is idle, dispatch a refresh and put the subscriber inwaiting, the way the first evaluation is handled, instead of returningAttach::Rejected.src/protocol/subscription/views.rs:34- Intent conformance. The intent says identical subscriptions must share one evaluation "keyed by the canonical query, knowledge graph, rules and visibility (authorization) state".ViewKeyholds onlyknowledge_graphandquery: String(the trimmed text). The docs say a query spelled differently, for example with other variable names, gets its own view. The key carries no rules or visibility component. Instead the evaluation runs withauth: None(reevaluate.rs:76), and read access is checked per subscriber at subscribe and at delivery. Authorization today is KG-level only (authorize_program/get_kg_role_for_user), so the rows look identical for every authorized principal. Persistent rules belong to the graph's snapshot, so this design looks safe. It still departs from the literal key the intent requires, and it uses trimmed text where the intent says canonical query. Please confirm the substitution is acceptable, or require a canonicalized query plus a visibility or rules component in the key.src/protocol/subscription/hub.rs:130- Round 1 fixed view-key-vs-intent by retiring view keys when a rule changes, but a stale evaluation can still reach a new subscriber. The fix does key retirement only when the worker processes the RuleChange/KgChange notification (hub.rs:141-142). The worker'sselect!isbiasedtoward commands (hub.rs:129-130). Rule changes are published to the broadcast synchronously during the write (write_run.rs:156 -> send_notification), before the client gets its reply. Failing sequence: agent A runs.rule add r(X) :- a(X), b(X). Agent B (or A itself) sees the reply and sends.subscribe s ?r(X). The worker has a broadcast backlog, for example from a burst of writes, or both events are ready in the same poll. The worker handles the Attach command first.ViewRegistry::attachfinds the old key, which is not retired yet, and returnsAttach::Attachedwith the publication computed under the old rules. B's snapshot reflects rules that were already replaced when it subscribed. A delta follows only after the notification is drained. This contradicts the recorded operator decision "so a rule change can never serve an old evaluation to a new subscriber". The same ordering lets a new subscriber's snapshot predate a write that was already acknowledged. Smallest fix at the shared boundary: inWorker::on_command, before handlingCommand::Attach, drainself.notifications.try_recv()until it is empty, applying each item through the same on_rule_change/on_change/on_unknown_changes logic as the select arm. Then every commit published before the attach command was sent is applied before key lookup. Detach stays command-first, as the module doc requires. Add a test: queue a RuleChange on the broadcast, then send the attach, and assert the subscriber gets a fresh view.src/protocol/subscription/views.rs:478- This comes from round 1's failed-view-rejects-new-subscribers fix. Now every new.subscribeto a failed view triggers a retry (views.rs:207-213). When the retry fails again,publishcreates anotherOutcome::Failedpublication and rings every existing subscriber (views.rs:478-494). Each existing subscriber then gets another identicalsubscription_errorpush, even though nothing it watches changed. Concrete case: a query's result exceedsmax_result_rows, so the view keeps its last complete result and is Failed. Each.subscribeattempt from other users or connections now sends a duplicate cap error to every current subscriber. The noise is driven by other principals' actions and grows with subscribe attempts. Fix: inpublish'sErrarm, whenlatest.outcomeis alreadyFailedwith the same message, skip creating a new publication and ringing subscribers. The waiters are still answered with the error throughrelease_waiting, becausefailureis computed separately inon_complete.🔧 Fix applied.
✅ Re-checked - no issues remain.
src/protocol/subscription/views.rs:207- This defect is in code that fix rounds 2/3 introduced for retry-failure-republishes-to-existing, and it leaves that fix incomplete.attachsetsview.retry = view.due.is_none() && view.query.is_some()unconditionally, so a second subscriber attaching while the retry is in flight (query is None) resetsretryto false. Sequence: the view is Failed('capped'), with subscriber 1 attached. Subscriber 2 attaches, so retry=true and the retry is dispatched. Subscriber 3 attaches while it runs, so retry=false and 3 joinswaiting. The retry fails again with 'capped'.publish(views.rs:477) no longer matches the retry guard, so it creates a new Failed publication and rings subscriber 1 with a duplicate identicalsubscription_error. That contradicts the round-2 operator instruction ('a retry triggered by a new subscriber that fails again must answer only the waiting subscribers') and the docs (websocket-api.mdx: 'existing subscribers get no duplicate subscription_error'). The concurrent case is common because the retry is single-flight precisely so that several subscribers arriving together share it. The testa_refresh_keeps_the_result_and_a_new_subscriber_reevaluates_once(failures.rs:45-58) covers two waiters only on the success path, anda_retry_failing_the_same_way_answers_only_its_waiterscovers one waiter, so neither catches this. Fix: update the flag only when this attach actually dispatches, e.g.if view.query.is_some() { view.retry = view.due.is_none(); }, and leave an in-flight retry's flag unchanged. Add a test: two waiters, the retry fails with the same error, and existing subscriber 1 is not rung.src/protocol/subscription/views.rs:200- A.subscribesent after an acknowledged data write can get a snapshot that does not include that write. Before this change, every.subscriberan a fresh evaluation. Nowattachreturnslive.latestwhenever the view's latest publication is not Failed. This happens even when the drain inWorker::on_command(hub.rs:162) has just seen a relevant commit and dispatched or scheduled a refresh (the view is in flight, haspending, or hasdueset). Concrete sequence: agent A holds a subscription to?order(X). Agent B runs+order(42), gets the reply, then sends.subscribe s ?order(X). The drain dispatches the view's refresh, thenattachreturnsAttachedwith the pre-write publication. B's snapshot lacks 42, which arrives only in a later delta. The snapshot is labelled with its olderrevision, so a client following the protocol converges, but read-your-writes on the snapshot is lost. Fix round 2 closed this gap for rule changes only (rule-change-attach-race; the docs promise a fresh evaluation only after a rule change), and its finding text noted that the data-write case stays open. Whether a subscriber may attach to a result older than writes acknowledged before its.subscribeis a product decision. Closing it means making such subscribers wait for the refresh after their pending change, not just the one in flight (views.rs:205-212waiting, plusfollow_uprelease). That trades a little subscribe latency for consistency. Please decide whether to accept the current behavior and document it in websocket-api.mdx, or require the wait.🔧 Fix applied.
✅ Re-checked - no issues remain.
🔧 **Test** - 2 issues found → auto-fixed ✅
src/protocol/subscription/views.rs:477- Commit 9730d39 addedErr(e) if matches!(&latest.outcome, Outcome::Failed(f) if *f == e) => return Vec::new()topublish. It is meant to stop a retry triggered by a new subscriber from ringing existing subscribers again. But it also drops errors from commit-triggered refreshes. While a view stays over max_result_rows, each later relevant commit now pushes nothing to existing subscribers. Before this change they got another subscription_error each time. The integration testtest_crossing_result_cap_keeps_last_complete_result(tests/standing_query_tests/main.rs:397) expects an error push for each of +a(4), +a(5), -a(1). It times out deterministically: 2 of 2 runs failed. A live run against the binary shows the same thing: the second over-cap commit gets no push within 2s. The operator's round-2 decision only covered a retry started by a new subscriber that fails again. Suggested fix: skip the publication only when the refresh was a subscriber-triggered retry, for example by tracking a retry flag on the refresh. Keep publishing commit-triggered failures. Alternatively, if suppressing repeat errors is now the intended product behavior, update the test and the docs. The docs (websocket-api.mdx:422) still say an over-cap re-evaluation pushes a subscription_error.cargo test --test standing_query_tests(end-to-end over real ws against an in-process server; 19 pass, 1 fails: test_crossing_result_cap_keeps_last_complete_result)cargo test --test standing_query_tests crossing(re-run, fails again: deterministic)cargo build --bin inputlayer-serverand launched with a disposable temp config/data dir on 127.0.0.1:18931Live Node WebSocket client (drive.mjs) S1-S6 against the server with max_result_rows=100000: shared snapshot/deltas for admin+viewer, other-KG isolation, unrelated write, revoke/regrant, unsubscribe + id reuse, rule change before subscribeLive Node WebSocket client S7 against the server with max_result_rows=3: crossing the cap, a repeated over-cap commit, a new subscriber retrying a failed view, recovery deltacargo test --lib protocol::subscription(45 pass, including colliding_keys_never_share_a_view, colliding_hashes_never_confuse_rows, an_attach_after_an_announced_rule_change_starts_a_new_view, a_retry_failing_the_same_way_answers_only_its_waiters)🔧 Fix applied.
✅ Re-checked - no issues remain.
cargo build --bin inputlayer-servercargo test --test standing_query_tests(20/20 pass, including test_crossing_result_cap_keeps_last_complete_result, which failed in round 1)cargo test --lib protocol::subscription(46 pass, including views::tests::failures::a_retry_failing_the_same_way_answers_only_its_waiters and a_commit_failing_the_same_way_still_reaches_every_subscriber)Live: ran two isolated inputlayer-server instances (temp data dirs, ports 18731 with max_result_rows=3 and 18732 at the default) and drove them over /ws with a Node WebSocket driver (PHASE=shareandPHASE=cap node drive.mjs)✅ No issues found.
cargo build --bin inputlayer-server(rebuilt at HEAD 0cc9f79f)run_phase.round3.sh share 18471(live WS driver: S1-S6 sharing/auth/unsubscribe/rule change)run_phase.round3.sh cap 18472 INPUTLAYER_STORAGE__PERFORMANCE__MAX_RESULT_ROWS=3(live S7-S8 result cap, retry, recovery)N=200 run_phase.round3.sh ryw 18476 INPUTLAYER_HTTP__RATE_LIMIT__WS_MAX_MESSAGES_PER_SEC=100000(live S9 read-your-writes, agent A/B order(42) pattern x200)run_phase.round3.sh waiters 18474 INPUTLAYER_STORAGE__PERFORMANCE__MAX_RESULT_ROWS=3(live S10 three concurrent new subscribers on a failed view, 5 rounds)run_phase.round3.sh coalesce 18475 INPUTLAYER_HTTP__RATE_LIMIT__SUBSCRIPTION_COALESCE_MS=100(live S11 coalescing window)cargo test --test standing_query_tests(20 integration tests incl. sharing::*)cargo test --lib protocol::subscription(50 unit tests incl. shared-evaluation counts and retry waiters)cargo test --lib colliding(query-key and result-tuple hash collisions)🔧 **Document** - 1 issue found ✅
gui/lib/docs-bundle.ts:3572- The committed generated bundlegui/lib/docs-bundle.tsstill holds the old websocket-api.mdx text (for example 'at most one evaluation runs per subscription', 'itsseqis skipped', 'standing queries re-evaluate'). It is built from docs/content bygui/scripts/bundle-docs.mjs, and the guiprebuild/build/devscripts run that step, so built artifacts are correct. The committed copy had already drifted before this change: running the script now also pulls in the backup guide, API key expiry and rotation, and other earlier docs. I left the copy unchanged to stay in scope. Suggested follow-up: regenerate it in its own commit, and either add a drift check in CI or stop committing the generated file.✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
✅ No issues found.