fix(storage): bound Blazegraph queries server-side, and refuse truncated CONSTRUCT results - #2094
Conversation
…ted CONSTRUCT results Aborting a query client-side does not stop it. The adapter puts a 30s AbortController on every store request but sends no server-side deadline, so when the client gives up Blazegraph keeps executing and allocating. On the V10 Base mainnet fleet this was measured directly: `/bigdata/status?showQueries` showed a steady pool of 2-5 abandoned queries with elapsed times of 10-32+ minutes, one aged +617s over 576s of wall clock (i.e. still running), while `queryErrorCount = 0` across 20,953 accepted queries confirmed the server had never cancelled anything. A handful of these saturate a 4-CPU core, which is what drives the `store scheduler queue wait timeout` and `exceeded its 30000ms deadline` failures that dominate Base publish errors. Send `X-BIGDATA-MAX-QUERY-MILLIS` on both read paths (SELECT/ASK and CONSTRUCT). The deployed 2.1.x engine honours it. The deadline is deliberately WIDER than the client's (4x, so 120s against the 30s default) rather than tighter. Bounding abandoned work does not require a tight bound — any finite value turns "unbounded" into "at most 2 minutes" — and a tight one introduces a correctness bug: Blazegraph commits 200 OK and starts streaming before it knows the query will finish, then appends its error into the already-committed body. A CONSTRUCT killed mid-stream while the client is still reading therefore yields a short but structurally valid n-quads document, and the tolerant parser (`if (!match) continue;`) silently drops the truncated tail — a sync page that looks complete and is not. Keeping the server bound above the client deadline means the client always aborts first, so no caller is ever parsing a truncated body. `assertCompleteNQuads` is the belt-and-braces guard for the residual case (e.g. an operator-set global `web.xml queryTimeout`, or an engine error mid-result). It is deliberately narrow — it detects truncation, not any unparseable line — so it cannot start rejecting the odd-but-harmless serialisations the tolerant parser has always accepted: it looks for an appended Java exception marker, or a final statement that does not end in `.`. Tests cover the header on both read paths, that it scales with a custom client timeout and stays above it, rejection of both truncation shapes, and that complete bodies (including empty results and comment lines) still parse. Verified by mutation: factor 1 fails the width assertion, and removing the guard fails both truncation tests. Full storage suite: 477 passed, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on the just-added assertCompleteNQuads, both real: 1. The `at com.bigdata.` alternative was not line-anchored, so a STORED literal containing Java-stack-trace text (user-published content is arbitrary — a KA holding error logs is plausible) would make every read of its graph throw "truncated". The engine appends its failure text as standalone lines, while literal content sits mid-line — n-quads forbids raw newlines in literals — so anchoring to line start keeps the true positive and removes the data-dependent false one. 2. The final-statement check took the literal last line, so a body ending with a comment line would be wrongly rejected (comments do not end with `.`). It now skips trailing blank and comment-only lines before judging completeness; a comments-only body is complete. Regression test covers both in one response body. 53 storage adapter tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Independently verified this on a real Blazegraph rig while working on a duplicate attempt (#2095, now closed in favour of this one). Posting the measurements as supporting evidence, plus one correction. Rig: pinned Residual server work after the client gave up (8 s budget, 4 runs/state):
The wider-bound decision is correct, and I have the failure it avoids. I independently built the tight-bound variant (
So a clean-boundary cut with no marker would slip through in principle, but every real truncation this image produces trips both signals. Worth a comment rather than a change. One correction — the UPDATE rationale is wrong on this image, though the code is safe. You write that the header "is read only by
This PR is unaffected because it doesn't send the header on Suite on this branch: 478 passed / 26 skipped. |
| @@ -239,6 +239,119 @@ describe('BlazegraphStore (mocked HTTP)', () => { | |||
| expect(String(init?.body)).toMatch(/^CONSTRUCT /); | |||
| }); | |||
There was a problem hiding this comment.
💡 Suggestion: Keep the new CONSTRUCT tests from turning the adapter suite into a near-1k-line catch-all
Why it matters
The file does not cross 1k lines yet, but this PR consumes most of the remaining space with feature-specific cases and repeated setup. A small decomposition now would keep the tests easier to scan and leave room for future adapter coverage without normalizing file sprawl.
Suggestion
Extract a small helper or table-drive the CONSTRUCT truncation cases, and consider moving this focused response-integrity suite into its own test file if more Blazegraph read-path cases are expected. Preserve the exact scenarios; the goal is to reduce scaffolding and keep the main adapter suite below the 1k-line pressure point.
Summary
Aborting a query client-side does not stop it. The adapter puts a 30s
AbortControlleron every store requestbut sends no server-side deadline, so when the client gives up Blazegraph keeps executing and allocating.
This is the root cause behind the two error classes that dominate Base mainnet publish failures
(
store scheduler queue wait timeout (normal: blazegraph.query)×151 andblazegraph operation exceeded its 30000ms deadline×123, together ~86% of the counted publish errors).Measured on the fleet
/bigdata/status?showQueries=detailson a Base core, container up with no restart in the window:queryErrorCountover 20,953 accepted queriesThread dumps showed 9
RUNNABLE com.bigdata.journal.Journal.executorService*threads in query operator frames whileall 10 Tomcat
http-nio-8080-execthreads were idle — nobody was waiting on those queries. They are pure waste,and a handful of them saturate a 4-CPU core.
The fix
Send
X-BIGDATA-MAX-QUERY-MILLISon both read paths (SELECT/ASK and CONSTRUCT). The deployed 2.1.x engine honoursit (
BigdataRDFContext$AbstractQueryTaskreads it viagetQueryTimeout).Why the bound is wider than the client deadline, not tighter
This is the design decision worth reviewing.
SERVER_QUERY_DEADLINE_FACTOR = 4, so the 30s default yields a 120sserver bound.
Bounding abandoned work doesn't need a tight bound. Any finite value converts "unbounded" into "at most 2
minutes". The orphan pool is what saturates the core, and the pool size is a function of arrival rate × lifetime
— cutting lifetime from 32 min to 2 min is a ~16× reduction.
A tight bound introduces silent data loss. Blazegraph commits
200 OKand begins streaming before it knowsthe query will finish, then appends its error into the already-committed body. A CONSTRUCT killed mid-stream
while the client is still reading yields a short but structurally valid n-quads document — and
parseNQuadsTextdoesif (!match) continue;, silently discarding the truncated tail and the appended error.The caller gets a sync page that looks complete and is not.
Keeping the server bound comfortably above the client deadline means the client always aborts first, so no
caller is ever parsing a truncated body. The failure stays loud.
Belt-and-braces:
assertCompleteNQuadsFor the residual case (an operator-set global
web.xml queryTimeout, or an engine error mid-result), CONSTRUCTbodies are checked for truncation before parsing.
Deliberately narrow — it detects truncation, not any unparseable line — so it cannot start rejecting the
odd-but-harmless serialisations the tolerant parser has always accepted. It fires on exactly two signals:
.Testing
Six new cases in
packages/storage/test/blazegraph.unit.test.ts:>the client deadlineMutation-verified:
SERVER_QUERY_DEADLINE_FACTOR = 1fails the width assertion; removingassertCompleteNQuadsfails both truncation tests.
Full storage suite: 477 passed, 0 failures (32 files).
tsc --noEmitclean.Scope / risk
getQueryTimeoutis referenced only fromAbstractQueryTask— not fromUpdateTask— so theheader is inert on
sparqlUpdate. Long INSERT/replaceGraph/deleteByPattern work remains unbounded server-side;that is a separate change and is not claimed here.
Related