Skip to content

[F40] bound query_state batch work: shared event budget, atomic reads - #5266

Open
bilboquet wants to merge 2 commits into
mainfrom
5057-f40-unbounded-query_state-batch-processing-can-cause-execution-lock-contention-and-node-dos
Open

bilboquet wants to merge 2 commits into
mainfrom
5057-f40-unbounded-query_state-batch-processing-can-cause-execution-lock-contention-and-node-dos

Conversation

@bilboquet

@bilboquet bilboquet commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Attack

query_state holds execution_state.read() for the whole batch (up to 128 items via
gRPC max_query_items_per_request). Events items had no batch-wide bound: 7000 events
per item on the final-cache path plus a completely uncapped active-history path
(flat_map().collect()), i.e. up to ~896k events deserialized, collected and returned
under a single read lock from one remote batch. parking_lot::RwLock is fair, so the
execution writer (final/candidate slots) stalls behind the heaviest batch in flight.
Remote-triggerable node slowdown / stall with attacker-controlled gas upstream.

Fix (2 commits)

1. Atomic batch reads — Closes #5268 (F40 point 1, stronger than asked).
The spec asked for a comment; the PR enforces it by typing instead: batch helpers
(eval_query_item, *_under) take &ExecutionState borrowed from the guard, so
releasing the lock mid-batch is a compile error (E0505, verified), not a silent
corruption. Public trait signatures unchanged. Pure move, no behavior change.

2. Shared event budget — Closes #5255.
ExecutionQueryRequest.max_event_count, fed by new ApiConfig/GrpcConfig
max_events_per_query settings (default 7000): each Events item takes
min(remaining, per-item cap) and decrements what it returns; past zero it gets a
per-item TooLargeResponse error (exists since #5236, no proto change) instead of
fetching. The limit is applied to the final-cache and active-history paths
(take-after-fetch: cost proportional to the returned plus one truncated fetch).
Event bytes (data.len() + estimated 128 B overhead, marked for later measurement)
and datastore-key bytes (sum of key lengths) now count against max_response_size.
Single-shot event queries are unchanged.

Left intentionally out: early-stop iterators (documented optimization), per-request
key-count forwarding (still #5189), get_addresses_infos cap and slot deadline
(phases 3-4 of #5057).

Why no MIP

Node-local API behavior only. Block validity rules, serialization, hashing, execution
semantics and PoS draws are untouched. Observable change: batches that used to return
up to ~896k events now get per-item errors past the budget — recorded on the F42
breaking-changes list for builders (same list as #5189).

Tests

  • test_query_state_events_budget_shared: two identical Events items, budget of 1 —
    first takes it, second errors.
  • test_scan_datastore_count_none_is_unbounded: tripwire documenting the unbounded
    count=None per-item scan left while [F42] propagate the configured datastore key query cap from RPC/gRPC … #5189 is unmerged (breaks on purpose when it lands).
  • Existing datastore_manipulations (has an Events item) green unchanged.
  • cargo check --workspace, clippy clean.

Closes #5255
Closes #5268
Relates #5057

@bilboquet
bilboquet marked this pull request as ready for review September 14, 2026 16:43
@bilboquet bilboquet changed the title [F40] fix Unbounded query_state can cause DoS [F40] bound query_state batch work: shared event budget, atomic reads Sep 14, 2026
@bilboquet
bilboquet force-pushed the 5057-f40-unbounded-query_state-batch-processing-can-cause-execution-lock-contention-and-node-dos branch from cb57c02 to 7ae55c7 Compare September 14, 2026 18:19

@Leo-Besancon Leo-Besancon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM! Just have 1 comment inline

self.execution_state
.read()
.get_filtered_sc_output_event(filter)
.get_filtered_sc_output_event(filter, usize::MAX)

@Leo-Besancon Leo-Besancon Sep 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I know that would make a lot more changes to the interfaces, but I think it would be cleaner to have an option instead, and setting it explicitely to None in all the callers going through this path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unless I'm mistaken there is still usize::MAX limit here instead of None?

…wing

Phase 1 of issue #5057. query_state and the other batch getters must
read under a single execution_state read lock: the returned cursors and
fingerprint describe the whole batch, so releasing the lock between
items would silently corrupt reads while keeping a valid stamp.

A comment-only invariant cannot hold that: drop + re-acquire compiles
fine. Hence batch helpers (eval_query_item, *_under) take &ExecutionState
borrowed from the guard: they receive a read capability for an acquisition
they do not own and cannot release. A mid-batch drop is now a compile error
(E0505, verified with a temporary drop test, then removed).

Public trait signatures unchanged (acquire + delegate). Pure code move +
reindent, no logic change. cargo check + clippy clean, crate suite green.
Implements the #5255 spec (phase 2 of #5057): bound the work per batch,
never the lock.

- ExecutionQueryRequest.max_event_count: Option<usize> batch budget, fed by
  the pre-existing [execution].max_event_per_query setting (no new knob;
  ApiConfig/GrpcConfig carry it to the transports). Each Events item takes
  min(remaining, per-item cap) and decrements what it returns; past zero it
  gets a per-item TooLargeResponse error (exists since #5236, no proto change)
  instead of fetching. None means unbounded (explicit opt-out for empty
  requests and tests, never for transport batches).
- execution.rs get_filtered_sc_output_event takes a limit applied to the
  final-cache and active-history paths (take-after-fetch minimum: cost is
  proportional to the returned plus one truncated fetch).
- Event bytes counted against max_response_size (data.len() + estimated
  per-event overhead const, marked ponytail: for later measurement).
- Datastore keys counted against max_response_size (sum of key lengths).
- Single-shot get_filtered_sc_output_event unchanged (explicit unbounded,
  marked ponytail: as known follow-up).
- New tests: two Events items sharing a budget of 1 (first takes it, second
  errors); scan tripwire documenting the unbounded count=None hole left
  while #5189 is unmerged (breaks on purpose when it lands).
- Observable behavior change (new per-item errors where success before):
  F42 breaking-changes list, same as #5189.
@bilboquet
bilboquet force-pushed the 5057-f40-unbounded-query_state-batch-processing-can-cause-execution-lock-contention-and-node-dos branch from 5f11cca to 68fdd5b Compare September 15, 2026 09:26
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.

[F40] Enforce single-lock batch reads in execution query getters [Followup] Include Events and Datastore keys to query_state cumulative budget

2 participants