Skip to content

Commit c664307

Browse files
philcunliffeclaude
andauthored
Bounded query execution: heap-growth budget + scanColumn wiring (LLP 0097) (#295)
* Bounded query execution: heap-growth budget + scanColumn wiring (LLP 0097) Realizes LLP 0054/0055/0056 kernel-side against the pinned engines (squirreling 0.14.0 / icebird 0.8.13), closing the issue-#9 crasher class: an unbounded ORDER BY over ai_gateway_messages now refuses in ~1s at ~1.4GB peak RSS with a typed error instead of dying at 6GB. - sql.js: thread the abort signal into squirrelExecuteSql (LLP 0054 #signal-threading) and enforce a per-query heap-growth budget, checked INLINE on the scan path (every 4096 rows / per column chunk) plus a 100ms interval for post-scan phases. A timer-only watchdog measurably never fires during a hot query (event loop starved). Default 1GiB growth, HYP_QUERY_MAX_HEAP_MB / maxHeapBytes override, typed QueryExecutionBudgetError exported from hypaware/core/query. - storage.js: hide internal fields by scan-level projection instead of rebuilding every row (~5 allocations/row removed); forward scanColumn. - union-source.js: forward scanColumn by concatenating per-partition column streams, owning limit/offset over the merged stream (LLP 0055). - ai-gateway dataset.js: withSchemaColumns forwards scanColumn, null-filling a partition that physically lacks the column. - LLP 0097 records the Phase 0 measurements (LLP 0057) and the decision; editorial forward-refs on 0054/0057. Measured on the production cache (202k rows / 931MB): every benched query faster (up to -26% wall), none regressed; graph queries unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bounded query exec: terminal budget check, signal-listener cleanup, blank-env guard (LLP 0097) Review hardening for the heap-growth guard (dual-review of PR #295): - Terminal budget check after collect(): the inline guard samples only every 4096 rows / per column chunk and the interval watchdog cannot fire during a fully synchronous run, so growth concentrated in a sub-stride tail or in finalization could return a wrongly-successful result. One guard.check() after materialization closes that window. - Detach the linked upstream-signal abort listener in finally: a long-lived signal shared across many queries would otherwise retain the per-call controller closure. - resolveHeapBudgetBytes: a set-but-blank HYP_QUERY_MAX_HEAP_MB (how many config systems render an unset optional) resolved to Number('')===0 and silently disabled the guard. Only a non-empty value now counts as an override; blank/whitespace falls through to the measured default. Exports resolveHeapBudgetBytes for a deterministic unit test of the env resolution (the heap-growth path itself is not deterministically testable in a shared process). Full suite green (2152 pass); tsc build:types clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 597ac24 commit c664307

12 files changed

Lines changed: 736 additions & 32 deletions

hypaware-core/plugins-workspace/ai-gateway/src/dataset.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,35 @@ const SCHEMA_COLUMN_NAMES = AI_GATEWAY_SCHEMA_COLUMNS.map((c) => c.name)
168168
*/
169169
function withSchemaColumns(source) {
170170
const columns = Array.from(new Set([...source.columns, ...SCHEMA_COLUMN_NAMES]))
171-
return {
171+
/** @type {AsyncDataSource} */
172+
const wrapped = {
172173
columns,
173174
numRows: source.numRows,
174175
scan(options) {
175176
return source.scan(options)
176177
},
177178
}
179+
// Forward the column-stream hook so single-column aggregates stay on the
180+
// engine's streaming fast path. A partition that physically lacks the
181+
// requested column (the additive schema-drift case this wrapper exists
182+
// for) surfaces its values as `undefined` holes in the chunk; normalize
183+
// them to null, the same "this partition predates the column" value the
184+
// row path reads, so accumulators see one representation either way.
185+
// @ref LLP 0055 [implements]: withSchemaColumns forwards scanColumn; a partition lacking the column yields nulls, never throws
186+
if (typeof source.scanColumn === 'function') {
187+
const scanColumn = /** @type {NonNullable<AsyncDataSource['scanColumn']>} */ (source.scanColumn)
188+
wrapped.scanColumn = (options) => ({
189+
async *[Symbol.asyncIterator]() {
190+
for await (const chunk of scanColumn(options)) {
191+
for (let i = 0; i < chunk.length; i++) {
192+
if (chunk[i] === undefined) /** @type {unknown[]} */ (chunk)[i] = null
193+
}
194+
yield chunk
195+
}
196+
},
197+
})
198+
}
199+
return wrapped
178200
}
179201

180202
/**

llp/0054-bounded-query-execution.spec.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ adds options to `ExecuteSqlOptions`, not a new import surface or a shim.
161161
`ai_gateway_messages` volume on a representative box — deferred to
162162
[LLP 0057](./0057-bounded-query-execution.plan.md). Until measured, ship a
163163
safe-low default and let operators raise it.
164+
*Measured 2026-07-10; resolved by [LLP 0097](./0097-heap-growth-query-budget.decision.md),
165+
which enforces the budget as sampled heap growth (default 1GiB) from the
166+
kernel while per-operator buffered-row/byte accounting remains the engine
167+
follow-up.*
164168
- **Spill-to-disk.** A future external-merge / spilling path would let large
165169
`ORDER BY` / `GROUP BY` **complete** instead of refusing; named as a deferred
166170
follow-up in [LLP 0056](./0056-refuse-over-spill-or-truncate.decision.md), not

llp/0057-bounded-query-execution.plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
**Author:** Phil / Claude
77
**Date:** 2026-06-30
88
**Related:** LLP 0054, LLP 0055, LLP 0056
9+
**Extended-by:** LLP 0097 (Phase 0 measurements; Phases 1-3 realized kernel-side against squirreling 0.14 / icebird 0.8.13, where the engine already streams aggregates and implements `scanColumn` at the leaf)
910

1011
> Turns the bounded-execution spec ([LLP 0054](./0054-bounded-query-execution.spec.md))
1112
> and its two decisions ([LLP 0055](./0055-stream-aggregates-via-scancolumn.decision.md),
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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).

src/core/cache/storage.js

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import path from 'node:path'
2929
* @import { ColumnSpec, QueryScope, QueryStorageService, SinkContinuation } from '../../../hypaware-plugin-kernel-types.js'
3030
* @import { CachePartitioningDeclaration, ExtendedQueryStorageService } from '../../../src/core/cache/types.js'
3131
* @import { UsagePolicyResolver } from '../../../src/core/usage-policy/types.js'
32-
* @import { AsyncCells } from 'squirreling'
32+
* @import { AsyncDataSource } from 'squirreling'
3333
*/
3434

3535
/**
@@ -303,34 +303,40 @@ export function createQueryStorageService({ cacheRoot, getDeclaration, getSettle
303303
async dataSourceForTable(tablePath) {
304304
const source = await dataSourceForTable(resolveIcebergDir(tablePath))
305305
if (!source) return null
306-
return {
306+
const publicColumns = source.columns.filter((c) => !INTERNAL_FIELDS.includes(c))
307+
/** @type {AsyncDataSource} */
308+
const wrapped = {
307309
numRows: source.numRows,
308-
columns: source.columns.filter((c) => !INTERNAL_FIELDS.includes(c)),
310+
columns: publicColumns,
309311
scan(options) {
310-
const inner = source.scan({
311-
...options,
312-
columns: options.columns?.filter((c) => !INTERNAL_FIELDS.includes(c)),
313-
})
312+
// Internal fields are hidden by PROJECTION, not by rebuilding every
313+
// row: the inner scan is always given an explicit column list with
314+
// the internal fields already stripped (the advertised public set
315+
// when the caller asked for everything), so the rows it yields never
316+
// carry an internal column and can be passed through untouched. The
317+
// previous per-row rebuild (filter columns, re-key cells, re-spread
318+
// resolved) allocated ~5 objects per row on every query, which
319+
// dominated scan-side garbage on large datasets.
320+
const requested = options?.columns
321+
const columns = requested
322+
? requested.filter((c) => !INTERNAL_FIELDS.includes(c))
323+
: publicColumns
324+
const inner = source.scan({ ...options, columns })
314325
return {
315326
appliedWhere: inner.appliedWhere,
316327
appliedLimitOffset: inner.appliedLimitOffset,
317-
async *rows() {
318-
for await (const row of inner.rows()) {
319-
const filteredColumns = row.columns.filter((c) => !INTERNAL_FIELDS.includes(c))
320-
const filteredResolved = row.resolved
321-
? Object.fromEntries(Object.entries(row.resolved).filter(([k]) => !INTERNAL_FIELDS.includes(k)))
322-
: undefined
323-
/** @type {AsyncCells} */
324-
const filteredCells = {}
325-
for (const col of filteredColumns) {
326-
if (row.cells && col in row.cells) filteredCells[col] = row.cells[col]
327-
}
328-
yield { ...row, columns: filteredColumns, cells: filteredCells, resolved: filteredResolved }
329-
}
330-
},
328+
rows: () => inner.rows(),
331329
}
332330
},
333331
}
332+
// @ref LLP 0055 [implements]: forward the column-stream hook so the
333+
// engine's streaming-aggregate fast path stays lit through the storage
334+
// wrapper; internal fields are not advertised, so the engine can never
335+
// request one here.
336+
if (typeof source.scanColumn === 'function') {
337+
wrapped.scanColumn = (options) => /** @type {NonNullable<AsyncDataSource['scanColumn']>} */ (source.scanColumn)(options)
338+
}
339+
return wrapped
334340
},
335341

336342
async flushTable(tablePath, opts = {}) {

src/core/query/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// Reading parquet/Iceberg back from a BlobStore-backed query source is
55
// built on top of these helpers.
66

7-
export { executeQuerySql } from './sql.js'
7+
export { executeQuerySql, QueryExecutionBudgetError } from './sql.js'
88
export { parquetDataSource } from './parquet-source.js'
99
export { whereToParquetFilter } from './parquet-pushdown.js'
1010
export { unionSources, emptySource } from './union-source.js'

0 commit comments

Comments
 (0)