Repository navigation
Conversation
… over a per-graph memory budget with resource_exhausted A query that grows past storage.performance.max_query_memory_bytes (default 4 GiB) is stopped and refused with the new wire code resource_exhausted instead of growing the process; a write that would grow a knowledge graph's estimated fact bytes past storage.performance.max_graph_memory_bytes (default unlimited) is refused the same way. Nothing either would have changed is applied, deletes always pass, and recovery loads a graph over budget. The global allocator meters each thread's net allocation in a thread-local cell (no lock, no atomic), and the evaluator charges it to the request's RequestControl at its cooperative checkpoints. Recursive loops now check at every batch (one step of an iterative scope can run a whole fixpoint), and a stopped dataflow is dropped instead of being run to completion by execute_directly, so deadlines and cancels also stop recursive queries promptly.
…shipped per-query limits
…here; 60% cgroup budget
jsam
marked this pull request as ready for review
October 4, 2026 20:20
This was referenced Oct 4, 2026
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
please continue developing the input layer engine so we make sure that the symbolic engine is ready to go live and completely production ready while we develop the GoTM uh, market.
(Context: the captain approved the neurosymbolic decision engine plan; its milestone 'Engine readiness' makes the symbolic engine production-ready for the hosted decision API, where each tenant environment runs on an engine cell. This task is item EN-9 of that milestone: per-graph memory budget and per-query memory limit: refuse with
resource_exhaustedinstead of growing (new; today an inputlayer-server reached about 115 GiB running a runaway query and took the host down) — why: one tenant's query must never take down its cell, and the process must stay inside its container limit so it is not killed)What Changed
src/execution/memory.rs) that counts net heap bytes per thread. Request control charges each query's growth at the evaluator's cooperative checkpoints and stops a query once it goes overmax_query_memory_bytes(default 4 GiB), or once it grows while all in-flight queries together are overmax_total_query_memory_bytes. That total defaults to 60% of the cgroup v2/v1 memory limit, or no limit outside a container. A query stopped this way applies nothing, and the limit is also enforced after commits.max_graph_memory_bytesbudget based on an estimate of each graph's stored facts. Program and fact commits that would grow a graph past it are refused withStorageError::MemoryBudgetExceeded. Deletes are always accepted, and recovery can still load a graph that is over budget.resource_exhaustederror code to the WS protocol and maps every memory stop to it. The code is also added to the JS/Python SDK protocol types, the GUI types, the AsyncAPI spec and the error docs. The branch also documents the new settings and a sizing rule in the configuration and deployment guides,config.example.tomland the Kubernetes configmap, and raises the StatefulSet memory limit from 4Gi to 8Gi. A newtests/memory_limit_tests.rssuite covers the limits.Risk Assessment
Testing
I built the release server and drove it over WebSocket with a Node client. Each scenario ran on its own fresh server in a temp data dir. A memory-limited Docker container with its own cgroup namespace covered the container-limit case. All six live scenarios passed: - A runaway closure is stopped in about 300 ms with resource_exhausted. The message says nothing was applied, and the connection keeps serving. - After a commit, the query, .why and conditional-delete paths all report resource_exhausted, not validation. The delete applies nothing. - A write past the graph budget is refused atomically. Deletes and re-inserts still work, and each graph has its own budget. - Four concurrent runaways are each refused by the global pool, and the server keeps answering. - In a 1 GiB container with the shipped defaults, the derived 614 MiB pool (60% of 1 GiB) refused the runaway at a container peak of 632 MiB, with no OOM kill. - With the limits disabled, the same container was OOM-killed (exit 137), which reproduces the original incident. The exact under-limit 6-row case was covered only by an in-process integration test, so it is marked untested here. The 6 focused integration tests and the cgroup-default unit test also pass. There is no UI surface, so the evidence is CLI and WebSocket transcripts. No temp dirs, containers or server processes were left behind, and the worktree is clean.Evidence: Runaway query refused under per-query limit
Evidence: Post-commit query / .why / conditional delete report resource_exhausted
Evidence: Per-graph budget refuses growth, deletes still pass
Evidence: Concurrent runaways refused by global query pool
Evidence: 1 GiB container, limits disabled: OOMKilled (baseline)
Evidence: 1 GiB container, shipped defaults: refused, peak 632 MiB, no OOM
container memory.max: 1073741824 << (5635 ms) {"type":"error","id":"closure","message":"Request stopped: the queries running on the server hold their whole memory budget (storage.performance.max_total_query_memory_bytes); it was stopped before it began committing and nothing was applied. Retry later","code":"resource_exhausted"} container memory.peak: 662818816 oom_kill 0 container running=true OOMKilled=false WARN query_memory_limit_exceeded held_bytes=644260922 limit_bytes=4294967296 pool_budget_bytes=644245092 reason=ServerMemoryExhaustedEvidence: WebSocket driver and launchers
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** - 2 issues found → auto-fixed (4) ✅
🔧 Fix applied.
3 warnings still open:
deploy/kubernetes/base/configmap.yaml:28- The shipped deployment limits refuse ordinary queries over a graph that is still inside its own budget. The k8s base config sets max_query_memory_bytes = 512 MiB with max_graph_memory_bytes = 1 GiB. docs/content/docs/guides/deployment.mdx:78-79 sets 1 GiB per query against 4 GiB per graph. Query evaluation copies its input onto the metered compute thread:StaticSource::collectclones every matching tuple into a Vec beforeto_stream(src/code_generator/static_input.rs), and DD batches and arrangements copy the data again. The new unit testtest_memory_limit_stops_the_computationconfirms that a plain Scan is charged. Example: a graph of about 5M two-int tuples is about 600 MB estimated, which is under the 1 GiB budget. An unfiltered?edge(X, Y)or a join over it holds more than 512 MiB and gets resource_exhausted, even though nothing runs away. Either raise the per-query limit relative to the graph budget in these shipped examples, or document that queries scanning a whole graph need max_query_memory_bytes of roughly 1–2× the graph budget. The values are an operator-facing product decision.deploy/kubernetes/base/configmap.yaml:31- The fix round raised the shipped per-query limit to 2 GiB (the user asked for at least 2x the graph budget). With that value, the k8s base config breaks its own sizing rule at lines 25-27 and can no longer keep the process inside its container. statefulset.yaml:119 limits the pod to 4Gi and sets no CPU limit. Concurrency comes from the query semaphore, which allowsncpu - max(ncpu/4, 2)queries at once (src/protocol/handler.rs:830-840), andavailable_parallelismreports all of the node's cores. Concrete case on a 4-core node: 2 permits. Two runaway queries can each hold just under 2 GiB, and one graph holds 1 GiB. That totals more than 5 GiB, so the pod is OOM-killed before either query hitsresource_exhausted. This contradicts the intent that 'the process must stay inside its container limit so it is not killed'. Larger nodes are worse: a 16-core node allows 12 permits, or 24 GiB of query memory. The old 512 MiB value already broke the rule on large nodes, but the fix round made it break on every typical node. The same problem appears in docs/content/docs/guides/deployment.mdx:78-79, which allows 8 GiB per query on all cores while its sizing table (around line 566) recommends 8-16 GB for medium production. Possible remedies, all involving product values: lower the graph budget (for example 512 MiB graph and 1 GiB query), raise the container memory limit, or cap concurrent compute permits through config so the documented sum holds. That choice is the author's.src/code_generator/mod.rs:1191- The per-query limit is enforced per worker thread, not per query. In the multi-worker path, each rayon worker installs its own(control, thread_net_bytes())baseline and charges only its own thread's growth againstmemory_limit. A query running on N workers can therefore hold about N x max_query_memory_bytes before any worker stops. This path is reachable in documented usage: deploy/kubernetes/base/configmap.yaml:21-22 tells operators to set num_threads to the requested CPUs, and num_threads becomes the KG's num_workers (src/storage_engine/mod.rs:340). Concrete case: num_threads = 8 and max_query_memory_bytes = 2 GiB. A non-recursive scan or join over a large graph, split into 8 partitions, can hold about 16 GiB. Yet deployment.mdx and the configmap describe max_query_memory_bytes as bounding 'a query's working set' and size the container per query. Remedy: charge each worker's growth to a shared per-request counter in RequestControl, for example an AtomicI64 of held bytes that each thread updates by its delta, and compare the sum against the limit. Alternatively, document the limit as per-worker and scale the sizing formula by num_threads. Which semantics to use is the author's call.🔧 Fix applied.
4 warnings still open:
deploy/kubernetes/base/configmap.yaml:28- The shipped deployment limits refuse ordinary queries over a graph that is still inside its own budget. The k8s base config sets max_query_memory_bytes = 512 MiB with max_graph_memory_bytes = 1 GiB. docs/content/docs/guides/deployment.mdx:78-79 sets 1 GiB per query against 4 GiB per graph. Query evaluation copies its input onto the metered compute thread:StaticSource::collectclones every matching tuple into a Vec beforeto_stream(src/code_generator/static_input.rs), and DD batches and arrangements copy the data again. The new unit testtest_memory_limit_stops_the_computationconfirms that a plain Scan is charged. Example: a graph of about 5M two-int tuples is about 600 MB estimated, which is under the 1 GiB budget. An unfiltered?edge(X, Y)or a join over it holds more than 512 MiB and gets resource_exhausted, even though nothing runs away. Either raise the per-query limit relative to the graph budget in these shipped examples, or document that queries scanning a whole graph need max_query_memory_bytes of roughly 1–2× the graph budget. The values are an operator-facing product decision.deploy/kubernetes/base/configmap.yaml:31- The fix round raised the shipped per-query limit to 2 GiB (the user asked for at least 2x the graph budget). With that value, the k8s base config breaks its own sizing rule at lines 25-27 and can no longer keep the process inside its container. statefulset.yaml:119 limits the pod to 4Gi and sets no CPU limit. Concurrency comes from the query semaphore, which allowsncpu - max(ncpu/4, 2)queries at once (src/protocol/handler.rs:830-840), andavailable_parallelismreports all of the node's cores. Concrete case on a 4-core node: 2 permits. Two runaway queries can each hold just under 2 GiB, and one graph holds 1 GiB. That totals more than 5 GiB, so the pod is OOM-killed before either query hitsresource_exhausted. This contradicts the intent that 'the process must stay inside its container limit so it is not killed'. Larger nodes are worse: a 16-core node allows 12 permits, or 24 GiB of query memory. The old 512 MiB value already broke the rule on large nodes, but the fix round made it break on every typical node. The same problem appears in docs/content/docs/guides/deployment.mdx:78-79, which allows 8 GiB per query on all cores while its sizing table (around line 566) recommends 8-16 GB for medium production. Possible remedies, all involving product values: lower the graph budget (for example 512 MiB graph and 1 GiB query), raise the container memory limit, or cap concurrent compute permits through config so the documented sum holds. That choice is the author's.src/protocol/handler.rs:2510- A memory stop after a commit is reported asvalidationeverywhere except a plain query. Round 1 (334b0c9) made memory limits apply to computations after the request began committing, but it remapped the error code only in QueryJob (src/protocol/handler.rs:3430). Two sibling paths that also compute after a durable statement still use a hard-codedErrorCode::Validation:.whyproof search (run_proof!, handler.rs:2510) and fact staging for conditional delete and update (src/protocol/handler/fact_staging.rs:51, applied at :91 and :103 throughinvalid, then passed through by write_run.rs:236). Example: the program.compactfollowed by-edge(X, Y) <- reach(X, Y)over a large closure..compactenters COMMITTING. Staging's evaluate then hits the per-query limit, socharge_memorysetsmemory_exceeded.supervise'sfinish()returns Ok because the request is committing, so nothing overrides the code, and the statement fails withcode: validationinstead ofresource_exhausted. The same happens for.compactfollowed by.why ?reach(X, Y), and for ServerMemoryExhausted. Fix: derive the code once at a shared point. For example, add a helper that returns ResourceExhausted whencurrent_request_control().memory_exceeded().is_some(), and use it in QueryJob,run_proof!, and the staging error mapping, so all three sites agree.deploy/kubernetes/base/configmap.yaml:24- The k8s sizing written in the round-2 fix leaves no headroom, so the pod can still be OOM-killed while every limit holds. The configmap and deployment.mdx (around line 600) budget 2 GiB of queries (the derived pool, half of 4Gi) plus 3 × 512 MiB of graphs plus 0.5 GiB of server, which is exactly the 4Gi pod limit. All three terms undercount real memory. (1) MeteredAllocator counts requested bytes (memory.rs:161-195), not RSS. glibc malloc headers, size-class rounding and per-thread arena retention add substantial overhead for DD's many small Tuple/Vec allocations, so 2 GiB metered is noticeably more resident. (2)max_graph_memory_bytesis an estimate (Tuple::estimated_bytesplus 32 bytes of index). It leaves out snapshot copy-on-write chunks that running queries keep pinned while writes or deletes rebuild a relation (data.retainin relation_store.rs). (3) Queries only charge at checkpoints, so they can overshoot between them. Concrete case: two queries near 1 GiB metered each, plus three graphs at budget, plus one delete rebuilding a pinned relation, exceeds 4Gi of RSS and the pod is killed. That contradicts the intent that 'the process must stay inside its container limit'. The base config also depends on detecting the cgroup at runtime for the pool (container_memory_limitreturns None if/sys/fs/cgroup/memory.maxis not the container's own cgroup), and then the pool silently becomes unlimited. Remedies involve product values: keep explicit headroom in the sizing rule (for example 20–25%), setmax_total_query_memory_bytesexplicitly in the configmap, lower the number of graphs the rule assumes, or raise the pod limit. The choice is the author's.🔧 Fix applied.
5 issues (4 warnings, 1 info) still open:
deploy/kubernetes/base/configmap.yaml:28- The shipped deployment limits refuse ordinary queries over a graph that is still inside its own budget. The k8s base config sets max_query_memory_bytes = 512 MiB with max_graph_memory_bytes = 1 GiB. docs/content/docs/guides/deployment.mdx:78-79 sets 1 GiB per query against 4 GiB per graph. Query evaluation copies its input onto the metered compute thread:StaticSource::collectclones every matching tuple into a Vec beforeto_stream(src/code_generator/static_input.rs), and DD batches and arrangements copy the data again. The new unit testtest_memory_limit_stops_the_computationconfirms that a plain Scan is charged. Example: a graph of about 5M two-int tuples is about 600 MB estimated, which is under the 1 GiB budget. An unfiltered?edge(X, Y)or a join over it holds more than 512 MiB and gets resource_exhausted, even though nothing runs away. Either raise the per-query limit relative to the graph budget in these shipped examples, or document that queries scanning a whole graph need max_query_memory_bytes of roughly 1–2× the graph budget. The values are an operator-facing product decision.deploy/kubernetes/base/configmap.yaml:31- The fix round raised the shipped per-query limit to 2 GiB (the user asked for at least 2x the graph budget). With that value, the k8s base config breaks its own sizing rule at lines 25-27 and can no longer keep the process inside its container. statefulset.yaml:119 limits the pod to 4Gi and sets no CPU limit. Concurrency comes from the query semaphore, which allowsncpu - max(ncpu/4, 2)queries at once (src/protocol/handler.rs:830-840), andavailable_parallelismreports all of the node's cores. Concrete case on a 4-core node: 2 permits. Two runaway queries can each hold just under 2 GiB, and one graph holds 1 GiB. That totals more than 5 GiB, so the pod is OOM-killed before either query hitsresource_exhausted. This contradicts the intent that 'the process must stay inside its container limit so it is not killed'. Larger nodes are worse: a 16-core node allows 12 permits, or 24 GiB of query memory. The old 512 MiB value already broke the rule on large nodes, but the fix round made it break on every typical node. The same problem appears in docs/content/docs/guides/deployment.mdx:78-79, which allows 8 GiB per query on all cores while its sizing table (around line 566) recommends 8-16 GB for medium production. Possible remedies, all involving product values: lower the graph budget (for example 512 MiB graph and 1 GiB query), raise the container memory limit, or cap concurrent compute permits through config so the documented sum holds. That choice is the author's.deploy/kubernetes/base/configmap.yaml:24- The k8s sizing written in the round-2 fix leaves no headroom, so the pod can still be OOM-killed while every limit holds. The configmap and deployment.mdx (around line 600) budget 2 GiB of queries (the derived pool, half of 4Gi) plus 3 × 512 MiB of graphs plus 0.5 GiB of server, which is exactly the 4Gi pod limit. All three terms undercount real memory. (1) MeteredAllocator counts requested bytes (memory.rs:161-195), not RSS. glibc malloc headers, size-class rounding and per-thread arena retention add substantial overhead for DD's many small Tuple/Vec allocations, so 2 GiB metered is noticeably more resident. (2)max_graph_memory_bytesis an estimate (Tuple::estimated_bytesplus 32 bytes of index). It leaves out snapshot copy-on-write chunks that running queries keep pinned while writes or deletes rebuild a relation (data.retainin relation_store.rs). (3) Queries only charge at checkpoints, so they can overshoot between them. Concrete case: two queries near 1 GiB metered each, plus three graphs at budget, plus one delete rebuilding a pinned relation, exceeds 4Gi of RSS and the pod is killed. That contradicts the intent that 'the process must stay inside its container limit'. The base config also depends on detecting the cgroup at runtime for the pool (container_memory_limitreturns None if/sys/fs/cgroup/memory.maxis not the container's own cgroup), and then the pool silently becomes unlimited. Remedies involve product values: keep explicit headroom in the sizing rule (for example 20–25%), setmax_total_query_memory_bytesexplicitly in the configmap, lower the number of graphs the rule assumes, or raise the pod limit. The choice is the author's.deploy/kubernetes/base/configmap.yaml:26- Round 3 added a sizing rule that does not keep the process inside its container, which the intent requires ('the process must stay inside its container limit so it is not killed'). The rule is: max_total_query_memory_bytes <= 0.6 × pod limit, and the sum of max_graph_memory_bytes <= max_total_query_memory_bytes. Stored graph facts are not part of the query pool. The pool only counts what computation threads hold while a request runs, andRequestControl::dropreturns that to the pool when the request ends (src/execution/request_control.rs:372). So the two budgets add up: queries 0.6 + graphs up to 0.6 = 1.2 × the pod limit, before any allocator overhead. Concrete case, with the k8s base as shipped: an 8Gi pod has a derived pool of about 4.8 GiB, and the configmap itself says 'Up to four graphs at their full budget fit the rule' at 1 GiB each. That is 8.8 GiB of metered bytes, so the pod is OOM-killed while every limit holds. The deployment.mdx example (around line 600) has the same problem: on a 32 GB host, 16 GiB of queries plus four graphs × 4 GiB is the whole host with no headroom. Yet the docs say 'The remaining 40% of the memory limit is headroom', which is false. The same wording appears in config.example.toml:98-102, docs/content/docs/guides/configuration.mdx:138-142, docs/guides/configuration.md and gui/lib/docs-bundle.ts. The rule matches the user's round-3 instruction word for word, so the fix needs a product decision. One option: make queries and graphs share the 60%, i.e. total query budget + sum of graph budgets <= 0.6 × limit (for an 8Gi pod, about 2.8 GiB of queries with four 512 MiB graphs, or set max_total_query_memory_bytes explicitly). Another: lower the derived default so it leaves room for graphs. Either way, fix the 'four graphs fit' and '40% headroom' claims.src/protocol/handler/fact_staging.rs:96- Round 3 remapped the post-commit memory stop on the fact-staging path (conditional delete and update, fact_staging.rs:96 and :107) to resource_exhausted. The round's test only covers.why(tests/memory_limit_tests.rsa_proof_after_a_commit_is_refused_as_resource_exhausted). No test runs.compactfollowed by-edge(X, Y) <- reach(X, Y)over a large closure, so a regression back tovalidationon this path would go unnoticed. A single integration case like the.whyone would cover it.🔧 Fix applied.
1 info still open:
src/protocol/handler/fact_staging.rs:96- Round 3 remapped the post-commit memory stop on the fact-staging path (conditional delete and update, fact_staging.rs:96 and :107) to resource_exhausted. The round's test only covers.why(tests/memory_limit_tests.rsa_proof_after_a_commit_is_refused_as_resource_exhausted). No test runs.compactfollowed by-edge(X, Y) <- reach(X, Y)over a large closure, so a regression back tovalidationon this path would go unnoticed. A single integration case like the.whyone would cover it.✅ No issues found.
✅ **Test** - passed
✅ No issues found.
.compactfollowed by.why ?reach(X,Y)over a large closure reports resource_exhausted, not validation-edge(X,Y) <- reach(X,Y)over a large closure is refused as resource_exhausted and deletes nothingcargo build --release --bin inputlayer-servercargo test --release --test memory_limit_tests(6 real-WebSocket integration tests: runaway query, runaway after commit, .why after commit, conditional delete after commit, query under the limit, graph budget)Live: inputlayer-server --config <tmp> with max_query_memory_bytes=32MiB, driven by a Node WebSocket client: unbound closure over 4496 edges, bound query, small query,.compact+.why ?reach(X,Y),.rule drop spare+-edge(X,Y) <- reach(X,Y), edge count afterwardsLive: max_graph_memory_bytes=64KiB: small write, oversized write,+note(1)program with an oversized write, delete-all, re-insertLive: max_query_memory_bytes=0, max_total_query_memory_bytes=48MiB: two concurrent closure queries on separate connections, then a bound queryLive baseline: everything unlimited, same runaway closure, peak RSS measured (1131 MiB)Live: server undersystemd-run --user --scope -p MemoryMax=512M+unshare -Urm -Cwith its own cgroup2 mounted at /sys/fs/cgroup, no explicit limits: startup log shows total_query_memory_bytes=322122546 (60%), runaway closure refused, process survives (peak 411 MiB)Live: server started with config.toml taken from deploy/kubernetes/base/configmap.yaml; startup log shows the 1 GiB per-query and 3 GiB total limits; listens normally✅ No issues found.
cargo build --release --bin inputlayer-serverrun.sh runaway- fresh server with max_query_memory_bytes=32MiB; unbound transitive closure over a 1500-node graph; follow-up small and bounded queries on the same connectionrun.sh compact-.compact+closure query,.compact+.why ?reach(X,Y),.rule drop spare+-edge(X,Y) <- reach(X,Y); edge count before and afterrun.sh graphbudget- max_graph_memory_bytes=64KiB: small write, oversized write, mixed program, delete, re-insert, second graphrun.sh pool- per-query 1GiB, max_total_query_memory_bytes=64MiB, 4 concurrent runaway closures, then a small queryrun-container.sh 18740 1gwith both limits set to 0 (contrast) - Docker ubuntu:24.04 with --memory=1g, 3000-node closurerun-container.sh 18741 1gwith shipped defaults (pool unset, derived from cgroup memory.max) - same 3000-node closurecargo test --test memory_limit_tests(6 integration tests over /ws)cargo test --lib test_total_query_memory_defaults_to_60_percent_of_the_cgroup_limit✅ **Document** - passed
✅ No issues found.
✅ No issues found.
🔧 **Lint** - 1 issue found ✅
deploy/kubernetes/base/configmap.yaml-make k8s-check(scripts/check-k8s-manifests.sh) could not run because kubectl is not on PATH, so the changed k8s manifests (configmap.yaml, statefulset.yaml with the 8Gi limit) were not validated. ruff is also not installed (and uv is offline), so the Python docstring was checked for line length by hand only.✅ No issues found.
✅ **Push** - passed
✅ No issues found.
✅ No issues found.