Skip to content

feature: cooperative query cancellation primitives#1303

Merged
bplatz merged 35 commits into
mainfrom
fix/query-timeout-cancellation
Jun 11, 2026
Merged

feature: cooperative query cancellation primitives#1303
bplatz merged 35 commits into
mainfrom
fix/query-timeout-cancellation

Conversation

@bplatz

@bplatz bplatz commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Query timeout and cooperative cancellation

Long-running queries had no way to stop early: a slow property-path traversal, a large star-join, or a multi-query bundle would hold server resources until it finished even if the client had already gone away or a wall-clock limit had been exceeded. This PR adds a complete cooperative cancellation system and wires it end-to-end.

Cancellation primitive (fluree-db-core)

QueryCancellation is a runtime-agnostic, atomic cancel handle. Three reasons are tracked — Cancelled, Timeout, and ClientDisconnected — and the first one signalled wins (compare-exchange). A disabled handle is a single None pointer, so callers that do not opt in pay only a branch at each checkpoint (~1 ns).

Query operator checkpoints (fluree-db-query)

check_cancelled() calls are placed at the hot iteration boundaries in:

  • property path traversal
  • direct index / binary scans
  • count plan execution (all overlay variants)
  • join, optional, minus, semijoin, union, subquery, aggregate, groupby, sort, and dataset operators

A cancelled query returns QueryError::Cancelled { reason } and unwinds normally — no panics, no leaked state.

Builder and execution wiring (fluree-db-api)

QueryExecutionOptions carries the cancellation handle and any lifecycle guards. ViewQueryBuilder and DatasetQueryBuilder each gain .execution_options() and .cancellation() methods. All internal query / query_tracked / query_view_with_r2rml call sites are updated to forward these options so the handle flows from server route → builder → execution context → checkpoints.

Server task isolation and disconnect detection (fluree-db-server)

run_query_task spawns query work in a separate tokio task. A drop guard on the awaiting side signals ClientDisconnected if the HTTP or MCP handler future is dropped before the query task completes — catching both graceful client closes and aborted requests without polling. A lightweight timeout task fires Timeout after the configured duration and then exits; its abort handle is kept in a second drop guard so it is cleaned up when the query finishes normally.

current_query_execution_options() uses a task-local to make already-installed options available to route helpers without threading them through every call site.

Configuration

Setting Env var Default
HTTP query timeout FLUREE_QUERY_TIMEOUT_MS 15 min
MCP sparql_query timeout FLUREE_MCP_QUERY_TIMEOUT_MS 5 min

Set to 0 to disable. Both are also settable via the config file.

Tests

  • Unit tests in cancellation.rs verify disabled/cloned/first-wins semantics.
  • Unit tests in query_control.rs verify disconnect signalling (waiter abort → ClientDisconnected), clean-completion no-signal, and timeout-fires-before-query-ends.
  • Integration tests in it_query_sparql_indexed.rs cover end-to-end cancellation through query execution.

bplatz added 15 commits June 8, 2026 09:56
Add a targeted CyclicBgpOperator for fixed-predicate triangle and 4-cycle BGPs to avoid left-deep nested-loop intermediate blowups. The operator keeps the existing sequential plan as a fallback, exposes EXPLAIN details, and can defer broad cycle edges into exact membership probes for LIMIT-heavy WGPB cases like S2-31/S2-11.

Document the new EXPLAIN fields and tuning env vars:
FLUREE_CYCLIC_BGP, FLUREE_CYCLIC_BGP_MAX_ROWS, and FLUREE_CYCLIC_BGP_DEFER_ROWS.
Extend CyclicBgpOperator so object-only cycle variables can join on late-materialized encoded object values instead of requiring every object to be an IRI ref. Keep the IRI-only guard for variables that must later act as subjects, preserving correctness for path-style cycles.

Also expose object-only encoded support in EXPLAIN and add focused tests for literal object-only squares and subject-bridge rejection.
Restore the original raw SID path for ref-only cyclic joins while keeping encoded object-key support for object-only cycle variables. This preserves the S2 holdout fixes without forcing path-style cycles and triangles through the more expensive Binding-based join path.

Expose the selected cyclic mode in EXPLAIN via object-only-values (`iri-ref` or `encoded`) and add tests for mode selection.
Lower the object-to-subject hash join probe floor so TI object in-stars use a single predicate scan instead of many scattered OPST seeks. Guard auto hash joins from wide intermediates to avoid draining multiway star cross-products before LIMIT can stop execution.
Extend the object-to-subject hash join cost gate so small driving sets can hash-join below the generic probe floor. This lets WGPB TI anchors avoid thousands of scattered OPST seeks while preserving the guard against draining wide multiway intermediates before LIMIT can stop execution.
Relax the object hash-join width guard so multi-edge TI in-stars can still hash when the intermediate is wide but estimated small.
Introduce a runtime-agnostic cancellation handle and thread server-configured query deadlines through API builders so long-running queries can stop at execution checkpoints.
External timeout monitors now signal cancellation through the shared handle, while long-running query operators check that handle at direct scan and merge boundaries.
Share query execution cancellation wiring across HTTP and MCP requests, including timeout-triggered cancellation and client-disconnect cancellation when request futures are dropped.

Add MCP-specific query timeout configuration with a 5 minute default via --mcp-query-timeout-ms, FLUREE_MCP_QUERY_TIMEOUT_MS, and [server.mcp] query_timeout_ms.
Run server-owned query execution in spawned tasks so request-drop can signal ClientDisconnected while the query task is still alive to observe cooperative checkpoints.

Share cancellation controls across HTTP, multi-query, and MCP sparql_query, including configurable server/MCP timeouts and request-scoped execution options.
Add focused query_control tests for disconnect-driven cancellation, normal completion disarming, and timeout signaling so the spawned query task behavior is covered.
@bplatz bplatz requested review from aaj3f and zonotope June 9, 2026 15:24
@bplatz bplatz force-pushed the perf/star-joins branch from a901cf1 to 2df456b Compare June 10, 2026 08:19
bplatz added 12 commits June 10, 2026 11:04
…ruct

Group the 7 query-level planning fields (required_where_vars, var_counts,
protected_vars, group_by, distinct_query, planning, stats) that were threaded
identically through build_triple_operators, build_sequential_triple_chain, and
build_sequential_join_block into a single TriplePlanContext<'_> struct.

Removes the #[allow(clippy::too_many_arguments)] annotations from all three
functions; the test wrapper constructs the struct with its known defaults.
Resolved conflicts:
- fluree-db-query/src/cyclic_bgp.rs: took perf/star-joins (superset; HEAD had no cancellation checkpoints here)
- fluree-db-query/src/hash_join.rs: took perf/star-joins (HASH_JOIN_MEDIUM_BUILD_MAX + de-benchmarked test names)
- fluree-db-query/src/execute/where_plan.rs: kept HEAD TriplePlanContext refactor, took perf/star-joins comment cleanup
- fluree-db-query/src/context.rs: merged imports (QueryCancellation from HEAD, FIRST_USER_GRAPH_ID from perf/star-joins)
- fluree-db-api/src/server_defaults.rs: kept HEAD query_timeout_ms config comment
- docs/operations/configuration.md: kept HEAD query_timeout_ms example
- docs/query/explain.md: took perf/star-joins expanded CyclicBgp explain fields
Three boundary-level checkpoints — no per-row overhead:
- next_batch: checked once per batch before enumeration
- open_ref_fast_path: checked once per edge during relation loading
- open_encoded_fast_path: same for the encoded-object path
…truct

Eight `*_with_r2rml*` methods had been pushed past the clippy
too_many_arguments threshold (7+self) by the `options: QueryExecutionOptions`
parameter added for query cancellation.  The `r2rml_provider` /
`r2rml_table_provider` pair already travels as a unit — ExecuteConfig
stores them as a 2-tuple — so bundle them into a `pub(crate)
R2rmlProviders<'a>` struct defined in fluree-db-api.

Also fix:
- `merge_modifier_intersect_range` in count_plan_exec: no natural grouping,
  add targeted `#[allow(clippy::too_many_arguments)]`
- Duplicate test name introduced by perf/star-joins merge: rename the
  regression variant to `indexed_inline_type_star_aggregate_overlay_undercount_regression`
- `query_execution_options(&state)` needless-borrow in server routes where
  `state: &AppState`
The CyclicBgpOperator relation loaders treated a predicate absent from
the base index dictionary as an empty relation. With an active overlay
the predicate's facts can live in novelty, so the empty relation zeroed
the whole BGP via the empty-driver early exit while the fallback
operator tree (whose cursors merge the overlay) found the matches.

All four loaders now decline (bail reason "novelty-only-predicate")
when the overlay may hold facts, routing to the overlay-correct
fallback. The empty-relation shortcut is kept when no overlay is
active, where it is genuinely correct.

Regression test covers both join modes (RefOnly directed triangle,
EncodedObject shortcut) against the fallback ground truth; verified to
fail without the fix.
… by predicate

Every collect_resolved_overlay_ops call re-walked the graph's entire
novelty and re-translated matching flakes via dict probes; count plans
and cursor builders repeat this several times per query for the same
predicate.

Three changes:

- Per-execution OverlayOpsCache on ExecutionContext, keyed
  (GraphId, order, Sid) with translation failure memoized. The cache
  lazily binds to a fingerprint of its inputs (overlay identity+epoch,
  to_t, dict novelty, runtime small dicts, store) and re-validates on
  every access, so a context derivation that shares it while changing
  any bound input computes uncached instead of reading stale ops (and
  trips a debug_assert). Lifecycle mirrors const_sid_cache: fresh on
  root constructors and with_graph_ref, shared on graph switches,
  reset by the binding-relevant builder setters. Misses compute
  outside the map lock.

- Predicate-bounded overlay walks: Psot/Post collections pass
  superset-safe boundary flakes so Novelty's slice_for_range binary-
  searches to the predicate's range instead of walking all novelty.
  The callback-side predicate filter remains the correctness backstop.

- BinaryCursor::set_overlay_ops takes Arc<[OverlayOp]>, so cached ops
  reach any number of cursors without copying; the no-overlay default
  is a shared empty slice (no per-cursor allocation).

Rewired through the cache: build_overlay_cursor_for_predicate (covers
cyclic full scans and count cursor builds), count_plan_exec,
fast_union_star_count_all, fast_post_order_limit. BinaryScanOperator's
own all-predicate translation path is unchanged (follow-up).
parallel_overlay_psot_filter_count and count_predicate_overlay_delta
still called collect_resolved_overlay_ops directly, so a declined
parallel lane or union/count fallback re-walked a predicate's novelty
that the cached cursor builder would fetch again later in the same
query. Both now share the per-execution cache.
…lane

The NestedLoopJoin batched lane reads base leaflets directly, so any
novelty on the graph forced the per-row fallback — a fresh right scan
per left row, each re-translating overlay state. The subject-probe lane
now stays live under a single-graph overlay by merging the right
predicate's resolved ops per probed subject.

ProbeOps (fast_path_common) mirrors merge_overlay_into_batch's case
analysis for set-of-matches consumers: an identity-matching retract
suppresses the base row, an identity-matching assert keeps it
(re-asserted fact), and unconsumed asserts are injected per probed
subject after the base scan — through the same bounds checks and
on_match closures as base rows, so inline filters and both scatter
shapes apply unchanged. Consumed-state is scoped to one flush: probed
keys recur across flushes with new left rows.

The gate (BatchedOverlayMode, recomputed per next_batch from the
per-execution ops cache) declines to per-row scans before any
accumulation when ops fail translation, the predicate exists only in
novelty, or the PSOT branch is absent while novelty exists. The object
(OPST) and exists lanes still require an overlay-free graph.

Injected values resolve through DictOverlay (novelty-minted subject and
string ids); the object-binding construction is factored out of
scan_matches and shared by base rows and injected asserts so both
produce identical representations.

Differential test: retract-drop, cross-commit retract+re-assert
netting, assert injection, novelty-only subjects, dict-novelty strings,
and FILTER bounds on injected rows — against a reindexed ground truth,
with span capture proving the batched lane engaged under novelty.
The strided per-group ticker still perturbed codegen of the fused merge
loops (+5-15% on wikidata-scale COUNT benchmarks); compiling checks out
entirely (cancel-checks-off) recovered baseline. Replace in-loop checks
with checkpoints on boundaries that already exist:

- leaflet/cursor-batch refill in PsotSubjectCountIter, PsotSubjectSeek,
  PsotSubjectWeightedSumIter, CursorSubjectCountStream (opt-in via
  with_cancellation; checked every few thousand rows, ~sub-ms of work)
- partition start in parallel_partition_count
- chain lane threads the handle through execute_chain

Hot loop bodies are bit-identical to pre-cancellation builds; cancellation
latency stays well under client-visible thresholds.
bplatz added 7 commits June 11, 2026 09:08
Accumulated keys from Sid/IriMatch bindings resolved only through the
persisted reverse dict, so a left subject minted after the last index
(e.g. bound via VALUES) could never enter the batched lane even though
the overlay merge can serve its facts — each such row paid a per-row
fallback scan. Resolution now falls back to DictNovelty, which yields
the same s_id space the overlay ops are translated into.

The differential test asserts engagement per query and, for the
VALUES-with-novelty-subject case, that both keys reach the accumulator
(flush span accum_len) — verified to fail without the fix.
Extends the subject-probe overlay merge to every consumer of the shared
leaflet-probe helpers: the batched EXISTS lane, single and grouped
OPTIONAL builders, and property-join's chunked probes and SPOT star
walk. All previously bailed to slower paths whenever the graph carried
any novelty.

ProbeOps generalizes to multi-predicate ops: p_id joins the fact
identity, so one reconciler serves both the single-predicate PSOT
probes and SPOT star probes over ops merged across the star's
predicates (both sort subject-major). A shared ProbeLanePlan
(Clean/Merge/Decline) centralizes the decline analysis — translation
failure, novelty-only predicate, branch absent with novelty, eager
materialization (Sid-space reasoning overlays keep the per-row path),
multi-graph — so every lane gates identically before accumulating.

Both helpers fate-check base rows per probed subject and inject
unconsumed asserts through the same bounds and emit paths as base rows
(filter decode is dict-overlay-aware for novelty-minted values). The
exists lane gains the dict overlay it previously lacked; OPTIONAL
subject resolution falls back to DictNovelty; the star walk gains
cancellation checks. OPTIONAL retract semantics fall out of
merge-before-ingest: a subject whose only match is novelty-retracted
yields an unbound optional row, never a dropped one.

property_join's subject keys normalize Sid to encoded id (persisted
dict, then DictNovelty) outside eager mode, collapsing the double-keyed
entries that kept the driver-id capture — and with it both batched
walks — from ever engaging when a scan fallback produced Sid rows.

Differential tests cover the exists lane (injected and retracted
matches), OPTIONAL retract-to-unbound, grouped OPTIONAL, and an
anchored property-join star whose subject exists only in novelty, each
against a reindexed ground truth with per-lane engagement assertions
(the test support layer now captures events as well as spans).
Split the property-join engagement assertion into one query per fast
lane: an anchored star proving the SPOT star walk engaged, and a star
containing a novelty-only predicate (which declines the whole-star
plan) proving the remaining base predicate took the chunked batched
probes. Both OPTIONAL builders now emit a completion debug event so the
single and grouped lanes each assert engagement directly instead of
relying on fallback-event absence (which passes vacuously when the
builder is never invoked — the grouped query was actually taking the
correlated per-row chain and is now shaped as consecutive single-triple
OPTIONALs, the form the grouped builder handles).

Also fixes a probe-coverage gap the new assertion exposed: property
join re-captured driver subject ids on every scan iteration, which
erases them after the first remaining predicate (the capture helper
returns None off the driver position) — so in an N-predicate star only
one predicate could ever take the batched probe path. Capturing only at
the driver position keeps later predicates probing.
…be lanes

Completes the overlay-aware probe conversion: the last two leaflet-probe
lanes that bailed (or silently read stale base state) under novelty now
merge the predicate's resolved ops per probed key.

Bound-object (OPST) lane: an object-major reconciler owns the
predicate's IRI_REF op subset re-sorted by (o_key, s_id, o_i) — served
from the already-cached PSOT ops, no second overlay walk — and
reconciles per probed object: retracts suppress matched base rows
(discriminating multi-entry @list refs by o_i, which required widening
the lane's projection beyond CORE; neither cache configuration decoded
OI before), unconsumed asserts inject through the same emit path. The
duplicated per-arm emit block is extracted so base and injected matches
share one construction, inline filters included.

Cyclic probes: per-edge ops are planned once at open (any unmergeable
edge disables probing for the cascade; full scans, which merge through
the overlay cursor, are unaffected) and merged inside both
scan-for-subjects loops via a new identity-yielding PsotSubjectSeek
variant that decodes OI. Injected asserts respect the ref-mode split
exactly like base rows. Since stats exclude novelty, each edge's op
count is folded into the probe-vs-scan estimate so novelty-heavy edges
don't look cheaper to scan than they are.

Also closes a policy leak introduced with the property-join subject-key
normalization: an all-Id key set enables the batched walks, which read
raw leaflets and bypass the per-leaf filter_flakes policy filtering —
under a restrictive policy the walk exposed rows the scans had hidden.
All probe lane plans now decline under a non-root policy enforcer and
the key normalization is skipped, restoring the scan path those views
always used.

Tests: bound-object differential under novelty (retract, inject,
novelty-only subjects, and a duplicate-@list retract where exactly one
o_i entry dies); the cyclic differential gains a novelty phase across
all three execution configs with a probe-engagement assertion; the
policy regression is covered by the existing it_policy_indexed suite.
…the overlay-free return

The non-root-policy decline sat after the overlay_free_single_graph()
short-circuit, so index-only (novelty-free) policy views still received
ProbeLanePlan::Clean — permission for the batched raw-leaflet lanes,
which never run the per-leaf filter_flakes policy filtering. The policy
check now precedes the overlay-free return in all three plans, making
the protection independent of novelty state.

Adds an index-only restrictive-policy tripwire test for the two-pattern
join shape: current routing keeps such queries off the batched lanes
upstream, so the assertion guards against any future routing change
exposing the raw path.
…s-off

The four per-visited-node checks from the original cancellation commit are
the only never-relaxed checkpoints on the transitive-path execution route.
Routing them through bail_if_cancelled keeps default behavior identical
while letting the cancel-checks-off control build rule them in or out of
the isolated transitive-path regression (8.5s vs 6.7s published).
…w-flake log storm

Typed xsd:decimal and overflow xsd:integer values are NumBig-arena-keyed
(per graph + predicate, handles minted at index build), so overlay
translation rejected every such flake as Unsupported. The consequences
compounded: on the range path each query re-warned per untranslatable
flake (the reported log storm — one ledger emitted two WARNs per
decimal novelty value on every update), and on the batched probe lanes
a single decimal in novelty declined the predicate's whole lane.

Translation now resolves big numerics read-only against the arena
(find_bigint/find_bigdec — the read-side arena rebuilds its dedup map,
so lookups work from the query path), mirroring the resolver's split:
i64-fitting integers encode inline, everything else by arena handle.
This covers every retract and every re-assert of an indexed value — the
dominant update pattern — keeping the batched lanes live. A value the
index has never seen still has no handle (minting one would need the
ephemeral-handle/decode side-table follow-up) and falls back as before.

The range path now logs one summary per translation call instead of one
line per flake: a debug summary for Unsupported-only outcomes (a
handled condition — the raw-flake merge is correct), warn when
unexpected errors occurred. Per-flake detail moves to debug.

Generic xsd:duration stays untranslated deliberately: its binary-path
decode is a stub (returns Null), so the raw flake preserves the value
where a translated row would not. The unused translate_overlay_flakes
(superseded by the _with_untranslated variant) is removed.

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm quite excited about this -- I think very large datasets where LLM behavior degraded because timed-out queries were still running on fluree resources will now improve drastically.

Claude found a few direct-leaf-read operators (i.e. not child operators that would inherit checkpoints) that have zero check_cancelled() sites:

  • fluree-db-query/src/fast_predicate_scalar_agg.rs (e.g. :357-393, :486-492
    — full leaf/leaflet/row walk of a predicate for SUM/MIN/MAX)
  • fluree-db-query/src/fast_min_max_string.rs
  • fluree-db-query/src/fast_post_order_limit.rs
  • fluree-db-query/src/fast_count.rs
  • fluree-db-query/src/binary_history.rs (history replay walk)
  • fluree-db-query/src/fast_label_regex_type.rs, fast_exists_join_count_distinct_object.rs, fast_sum_strlen_group_concat.rs

I'm not sure if any of them deserve cancellation checks within this PR or if we just want to file an issue to consider them additively in future PRs

@bplatz

bplatz commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

I'm quite excited about this -- I think very large datasets where LLM behavior degraded because timed-out queries were still running on fluree resources will now improve drastically.

Claude found a few direct-leaf-read operators (i.e. not child operators that would inherit checkpoints) that have zero check_cancelled() sites:

  • fluree-db-query/src/fast_predicate_scalar_agg.rs (e.g. :357-393, :486-492
    — full leaf/leaflet/row walk of a predicate for SUM/MIN/MAX)
  • fluree-db-query/src/fast_min_max_string.rs
  • fluree-db-query/src/fast_post_order_limit.rs
  • fluree-db-query/src/fast_count.rs
  • fluree-db-query/src/binary_history.rs (history replay walk)
  • fluree-db-query/src/fast_label_regex_type.rs, fast_exists_join_count_distinct_object.rs, fast_sum_strlen_group_concat.rs

I'm not sure if any of them deserve cancellation checks within this PR or if we just want to file an issue to consider them additively in future PRs

I'd like to take another pass at fine tuning this as we go forward, I had some super high performing queries in benchmarks degrade up to 15% because there were too many checks so already moved some things 'up' so they would not been the hot path as much.

The goal here is to, I'd say, fully cancel within say 1/4 of a second, not instantly. Some of these hot paths give instant but at a cost.

Base automatically changed from perf/star-joins to main June 11, 2026 21:27
Reword the feature comment (the experiment branch it referenced is gone)
and document the checkpoint placement policy + cancellation latency bound
in the query-timeout configuration docs.
@bplatz bplatz merged commit 84e4ef7 into main Jun 11, 2026
13 of 14 checks passed
@bplatz bplatz deleted the fix/query-timeout-cancellation branch June 11, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants