|
| 1 | +# LLP 0097: Heap-Growth Guard Enforces the Execution Budget from the Kernel |
| 2 | + |
| 3 | +**Type:** Decision |
| 4 | +**Status:** Active |
| 5 | +**Systems:** Query, Cache |
| 6 | +**Author:** Phil / Claude |
| 7 | +**Date:** 2026-07-10 |
| 8 | +**Related:** LLP 0054, LLP 0055, LLP 0056, LLP 0057 |
| 9 | + |
| 10 | +> How the kernel bounds query execution memory TODAY, with the pinned engine: |
| 11 | +> a sampled process-heap-growth guard checked inline on the scan path, refusing |
| 12 | +> per [LLP 0056](./0056-refuse-over-spill-or-truncate.decision.md). Realizes |
| 13 | +> [LLP 0054](./0054-bounded-query-execution.spec.md) `#memory-invariant` and |
| 14 | +> sizes the default ceiling `#execution-budget` deferred to measurement. |
| 15 | +
|
| 16 | +## Context |
| 17 | + |
| 18 | +[LLP 0057](./0057-bounded-query-execution.plan.md) Phase 0 (measure) ran on |
| 19 | +2026-07-10 against the production cache (202k-row / 931MB-on-disk |
| 20 | +`ai_gateway_messages`, 5.4k-node / 12k-edge context graph), one fresh CLI |
| 21 | +process per query, median of 3, peak RSS via `/usr/bin/time -l`: |
| 22 | + |
| 23 | +| query class | wall | peak RSS | |
| 24 | +|---|---|---| |
| 25 | +| process floor (`LIMIT 1`) | 173ms | 172MB | |
| 26 | +| `COUNT(*)` | 171ms | 179MB | |
| 27 | +| `COUNT(DISTINCT session_id)` | 759ms | 510MB | |
| 28 | +| `GROUP BY provider` / high-card `GROUP BY session_id` | ~780ms | 470-534MB | |
| 29 | +| top-K `ORDER BY ... LIMIT 20` | 888ms | 444MB | |
| 30 | +| full sort, narrow projection, no LIMIT | 987ms | 743MB | |
| 31 | +| `COUNT(DISTINCT content_text)` | 1085ms | 685MB | |
| 32 | +| `SELECT * ORDER BY` (no LIMIT, issue #9 class) | 18.3s | **6.1GB, died** | |
| 33 | +| `hyp graph neighbors` depth 1-3 | ~170ms | 130-150MB | |
| 34 | + |
| 35 | +Two facts changed the implementation picture since the 0054/0055 docs were |
| 36 | +authored against squirreling 0.12.24: |
| 37 | + |
| 38 | +- **The engine already streams.** squirreling 0.14.0 (pinned) streams scalar |
| 39 | + and `GROUP BY` aggregates through accumulators, sorts top-K when a `LIMIT` |
| 40 | + reaches the sort, threads an abort `signal`, and has the `scanColumn` |
| 41 | + column-stream fast path; icebird 0.8.13 (pinned) implements `scanColumn` at |
| 42 | + the leaf. The dormant pieces were all kernel wrappers, now lit |
| 43 | + ([LLP 0055](./0055-stream-aggregates-via-scancolumn.decision.md) `@ref`s in |
| 44 | + `src/core/cache/storage.js`, `src/core/query/union-source.js`, ai-gateway |
| 45 | + `dataset.js`). |
| 46 | +- **The remaining crasher is retained buffering** in blocking operators with |
| 47 | + no bound: the engine-side buffered-row/byte accounting that |
| 48 | + [LLP 0054](./0054-bounded-query-execution.spec.md) `#execution-budget` |
| 49 | + specifies is an upstream squirreling change that has not landed. |
| 50 | + |
| 51 | +Waiting for the engine accounting would leave the daemon OOM-killable in the |
| 52 | +meantime. The kernel needed an enforcement mechanism that works with the |
| 53 | +pinned engine, entirely from the `hypaware/core/query` surface. |
| 54 | + |
| 55 | +## Options considered |
| 56 | + |
| 57 | +1. **Sampled process-heap-growth guard in the kernel.** (Chosen.) Sample |
| 58 | + `process.memoryUsage().heapUsed` growth since query start; refuse when it |
| 59 | + exceeds the budget. |
| 60 | +2. **Wait for engine-side buffered-row/byte accounting** (LLP 0054 |
| 61 | + `#execution-budget` as specified). Rejected as the only line of defense: |
| 62 | + upstream latency leaves the crasher class live; the engine accounting |
| 63 | + remains the intended refinement and composes with this guard when it lands. |
| 64 | +3. **Timer-only watchdog** (setInterval + abort signal). Rejected as |
| 65 | + insufficient alone, from evidence: a query whose reads resolve without real |
| 66 | + I/O holds the event loop for its entire run, so timer callbacks never fire. |
| 67 | + The measured issue-#9 crasher ran 8+ seconds to 4.8GB with a 50MB budget |
| 68 | + and **zero** watchdog samples. |
| 69 | + |
| 70 | +## Decision |
| 71 | + |
| 72 | +`executeQuerySql` enforces a **per-query heap-growth budget** with two |
| 73 | +coordinated layers: |
| 74 | + |
| 75 | +- **Inline guard (primary):** every table source is decorated so its row scans |
| 76 | + check sampled heap growth every 4096 rows, and its column streams check per |
| 77 | + chunk, from *inside* the loop that a blocking operator drives. Starvation- |
| 78 | + proof by construction. |
| 79 | +- **Interval watchdog (secondary):** a 100ms `setInterval` covers execution |
| 80 | + phases that pull no further source rows (join amplification, output |
| 81 | + finalization) but do yield to the event loop. |
| 82 | + |
| 83 | +Either tripping aborts the run through the threaded signal |
| 84 | +([LLP 0054](./0054-bounded-query-execution.spec.md) `#signal-threading`) and |
| 85 | +surfaces a typed `QueryExecutionBudgetError` (exported from |
| 86 | +`hypaware/core/query`) carrying the limit and observed growth: a refusal, not |
| 87 | +a truncation ([LLP 0056](./0056-refuse-over-spill-or-truncate.decision.md)). |
| 88 | + |
| 89 | +**Growth, not absolute:** the budget bounds heap growth attributable to the |
| 90 | +query (sampled minus at-start baseline), so a long-lived daemon's resident |
| 91 | +baseline neither eats the budget nor causes blanket refusals. |
| 92 | + |
| 93 | +**Default ceiling: 1GiB growth**, from the Phase 0 measurements: every |
| 94 | +well-formed query in the measured set stays under ~500MB of growth (2x |
| 95 | +headroom), while the crasher class blows past 4GB. Operators override with |
| 96 | +`HYP_QUERY_MAX_HEAP_MB` (or the `maxHeapBytes` option on |
| 97 | +`ExecuteSqlOptions`; `0` disables). With the default in place the measured |
| 98 | +crasher refuses in 0.7-1.2s at ~1.4GB peak RSS instead of dying at 6GB. |
| 99 | + |
| 100 | +## Consequences |
| 101 | + |
| 102 | +- Every caller of `hypaware/core/query` (CLI, MCP `query_sql`, HypAware |
| 103 | + Server `POST /v1/query`) inherits the bound with no per-surface work |
| 104 | + ([LLP 0054](./0054-bounded-query-execution.spec.md) `#uniform-surface`). |
| 105 | + The server can pass its own `maxHeapBytes` and map the typed error to a |
| 106 | + 4xx (HypAware Server LLP 0020 owns that wiring). |
| 107 | +- Heap growth is process-global. Concurrent queries in one process share the |
| 108 | + observable, so a query can be refused partly on a neighbor's allocations; |
| 109 | + conservative and safe in the direction we care about (protect the process). |
| 110 | + Per-operator buffered-byte accounting (the LLP 0054 `#execution-budget` |
| 111 | + letter, upstream in squirreling) remains the precise refinement; when it |
| 112 | + lands, this guard stays as defense-in-depth. |
| 113 | +- `heapUsed` includes not-yet-collected garbage, so a pathologically |
| 114 | + garbage-heavy but well-bounded query could trip early; measured headroom |
| 115 | + (2x over the worst legitimate query) and the scavenge-on-allocation |
| 116 | + behavior of young-generation garbage make this unlikely, and the refusal |
| 117 | + message names the override. |
| 118 | +- Post-change measurements (same harness, same cache): every measured query |
| 119 | + got faster (up to -26% wall) and none regressed; the speed budget for this |
| 120 | + work ("within 10%") was met with margin. |
| 121 | + |
| 122 | +The code site (`src/core/query/sql.js`) carries `@ref`s to this decision and |
| 123 | +to [LLP 0054](./0054-bounded-query-execution.spec.md) `#signal-threading` / |
| 124 | +[LLP 0056](./0056-refuse-over-spill-or-truncate.decision.md). |
0 commit comments