Skip to content

feat: unified EVM worker pool - #2710

Open
gventino-cw wants to merge 7 commits into
mainfrom
unified-evm-pool
Open

gventino-cw wants to merge 7 commits into
mainfrom
unified-evm-pool

Conversation

@gventino-cw

Copy link
Copy Markdown
Contributor

Closes #2708

Summary

Replaces the per-kind EVM worker pools (call-present, call-past, inspector) with a single unified pool. Configs become per-kind concurrency limits enforced by counting semaphores, so the scenario of hundreds of idle workers in one kind while another kind is queued no longer happens.

Changes

  • Unified pool: one set of executor.evm_workers OS threads (default 150) pulling PoolTasks from a single bounded channel (4096). Each worker's Evm executes any kind — the point-in-time of each task is selected by its own input.
  • Per-kind limits: executor.call_present_limit, executor.call_past_limit and executor.inspector_limit cap concurrent in-flight tasks per kind. Permits are acquired on the sender side (own limit, then shared flex quota, then blocking on the own limit with a shutdown-aware condvar wait) and ride inside the task, released after execution — no head-of-line blocking, FIFO per kind.
  • Default policy: call_present_limit falls back to the pool capacity remaining after the other kinds' limits, so the busiest kind can use every idle worker.
  • Flex quota: executor.evm_flex_quota (default 0) adds extra permits that any saturated kind can borrow, sharing idle capacity across kinds. A value around 20% of the pool is the suggested starting point after soak testing.
  • Config migration: the old executor.call_present_evms / call_past_evms / inspector_evms fields are deprecated aliases; when only they are set, total workers = their sum, preserving pre-migration capacity. A warning is logged when they are used. sum(limits) > workers fails at startup (ExecutorConfig::init now returns anyhow::Result).
  • Semaphore generalization (src/utils.rs): try_acquire, acquire_shutdown_aware (condvar polling of GlobalState::is_shutdown), and pluggable metrics — the local transaction warmup semaphore keeps its legacy unlabeled metrics, other semaphores stay metric-less.
  • Metrics: new gauges executor_workers_total, executor_pool_permits_waiting{kind}, executor_pool_permits_held{kind} and executor_pool_queue_len; executor_workers_busy{pool} is now labeled by the task's kind since workers are shared.
  • Evm de-genericization: the <Input> type parameter was PhantomData-only; Evm::execute is now generic over the input argument instead, and inspect is inherent.
  • Transactions remain in the dedicated serial TransactionWorker (unchanged — they were never part of the pooled kinds).

Notes for reviewers

  • Per-kind limits count queued + executing tasks (permit acquired before enqueue), which is deliberately stricter than "running at the same time" — it also prevents one kind from flooding the shared queue.
  • The flex quota is off by default; the issue's ">20% free" idea maps to setting it to ~20% of the pool after soak testing.

Verification

  • cargo check --all-targets ✅
  • cargo +nightly-2026-05-08 fmt --all ✅
  • Clippy with lint-check flags (-D warnings) ✅
  • cargo test --lib — 232 passed ✅ (includes 7 new PoolConfig::resolve tests)
  • cargo test --test config_loader — 11 passed ✅

Replace the per-kind EVM worker pools (call-present, call-past, inspector)
with a single pool shared by every execution kind, closing #2708.

- One set of `executor.evm_workers` OS threads (default 150) pulling from a
  single bounded channel; each worker's EVM executes any kind, since the
  point-in-time is selected per task by its input.
- Per-kind concurrency limits (`executor.call_present_limit`,
  `executor.call_past_limit`, `executor.inspector_limit`) enforced by
  counting semaphores acquired on the sender side: the permit rides inside
  the task and is released after execution, so no kind can be head-of-line
  blocked by unrelated kinds and FIFO fairness is preserved.
- `call_present_limit` defaults to the pool capacity remaining after the
  other kinds, so the busiest kind can use every idle worker.
- `executor.evm_flex_quota` (default 0) adds extra permits any saturated
  kind can borrow, sharing idle capacity across kinds.
- Deprecated `executor.{call_present,call_past,inspector}_evms` fields are
  aliases that preserve the total capacity of pre-migration deployments.
- Generalized `Semaphore` with `try_acquire` and shutdown-aware acquire
  (condvar polling), with pluggable metrics; new pool gauges
  `executor_workers_total`, `executor_pool_permits_waiting`,
  `executor_pool_permits_held` and `executor_pool_queue_len`.
- De-genericized `Evm` (the `Input` parameter was PhantomData-only) and
  moved the worker loop into `EvmWorkerPool::worker`.
@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

@stratus-benchmark

Copy link
Copy Markdown

Benchmark:
Run ID: bench-0017909c

Git Info:

Leader Stats:
RPS Stats: Max: 9594.00, Min: 192.00, Avg: 7116.90, StdDev: 1890.94
TPS Stats: Max: 9633.00, Min: 31.00, Avg: 7033.36, StdDev: 2105.23

Follower Stats:
Imported Blocks/s: Max: 63.00, Min: 2.00, Avg: 50.00, StdDev: 21.60
Imported Transactions/s: Max: 481439.00, Min: 15018.00, Avg: 351668.17, StdDev: 177334.76

Plots:

@gventino-cw

Copy link
Copy Markdown
Contributor Author
  • View Leader Plot

compared this with this (it seems ok but the std dev increase it's kinda sus):

#2696 (comment)

@gventino-cw
gventino-cw marked this pull request as ready for review September 25, 2026 18:30
@gventino-cw
gventino-cw requested a review from a team as a code owner September 25, 2026 18:30

@cloudwalk-review-agent cloudwalk-review-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Nice refactor overall — the unified pool + per-kind semaphores is a meaningful improvement and the config-resolution tests are solid. I found one blocking correctness issue around task submission error handling that can leak permits and return the wrong RPC outcome for inspect calls.

Blocking finding

The inspect() path ignores send failures (let _ = self.tx.send(task);). If the worker queue/channel is closed, this still waits on inspector_rx.recv() and then reports ChannelClosed from the oneshot side instead of surfacing the real enqueue failure. More importantly, because the Permit is moved into PoolTask and the task is dropped on send error, this path should fail immediately and deterministically, mirroring execute() behavior.

Please handle self.tx.send(task)? (or equivalent mapped error) in inspect() as done in execute(), and only wait on the oneshot when enqueue succeeds.

@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

Comment thread src/eth/executor/config.rs Outdated
Comment thread src/eth/executor/config.rs Outdated
Comment thread src/eth/executor/config.rs Outdated

@cloudwalk-review-agent cloudwalk-review-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Good refactor direction overall (unified pool + admission control), but I found one blocking correctness issue still present in the updated code path.

Blocking finding

inspect() still ignores queue send failures (let _ = self.tx.send(task);). If the pool channel is closed/full-erroring, the function proceeds to wait on the oneshot and eventually returns ChannelClosed from the response side instead of surfacing the real enqueue failure immediately. This also diverges from execute() behavior, which correctly propagates self.tx.send(task)?.

Please handle send failure in inspect() the same way as execute() (propagate/convert the enqueue error before waiting on inspector_rx.recv()).

@stratus-benchmark

Copy link
Copy Markdown

Benchmark:
Run ID: bench-c2921f1d

Git Info:

Leader Stats:
RPS Stats: Max: 9373.00, Min: 286.00, Avg: 7041.30, StdDev: 1870.12
TPS Stats: Max: 9641.00, Min: 17.00, Avg: 6953.41, StdDev: 2097.28

Follower Stats:
Imported Blocks/s: Max: 61.00, Min: 4.00, Avg: 43.00, StdDev: 19.12
Imported Transactions/s: Max: 468726.00, Min: 28659.00, Avg: 298996.57, StdDev: 140897.00

Plots:

Comment thread crates/stratus_metrics/src/definitions.rs
Comment thread crates/stratus_metrics/src/definitions.rs Outdated
Comment thread crates/stratus_metrics/src/definitions.rs Outdated
Comment thread src/eth/executor/types/task.rs
Comment thread src/eth/executor/config.rs Outdated
Comment thread src/eth/executor/config.rs Outdated
Comment thread src/eth/executor/pool_admission.rs Outdated
Comment thread src/eth/executor/pool_admission.rs
Comment thread src/eth/executor/pool_admission.rs Outdated
Comment thread src/utils.rs Outdated
Comment thread src/eth/executor/evm_worker_pool.rs Outdated

@cloudwalk-review-agent cloudwalk-review-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Blocking issue found in the new unified pool admission path: the global inflight_total counter is updated with a non-atomic check-then-increment sequence, which allows concurrent acquires to over-admit through the relaxed path well past the configured busy threshold. This breaks the intended transition point where per-kind throttling should start and can materially exceed configured concurrency controls under load.

I did not find other concrete correctness/security issues in the provided diff context.

@cloudwalk-review-agent cloudwalk-review-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

I reviewed the unified EVM pool refactor, including admission control, task routing, config migration surface, and concurrency behavior in the provided diff context. The previously reported over-admission race (inflight_total check-then-increment) is now addressed by moving per-kind admission accounting behind mutexed gates and using a single atomic total counter update after successful admission.

The new pool admission logic appears internally consistent (permit lifecycle via RAII, throttled wait with shutdown escape hatch, and per-kind metrics updates), and the EVM de-genericization changes are propagated through executor and transaction worker call sites without obvious type/behavioral regressions.

Config and validation changes are coherent with the new model (flattened PoolConfig, bounded clap ranges, warning-level cross-field validation), and there are focused tests for pool defaults/limits and config-loader merge semantics.

No concrete blocking correctness or security issues were found in the supplied patch/context.

type Input = Input;
/// Returns the pool kind of a call: calls against the latest state and calls against a past state
/// are admitted by different pool gates.
fn call_evm_kind(input: &CallExecutionInput) -> EvmKind {

@carneiro-cw carneiro-cw Sep 29, 2026 •

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 think we should rethink EvmKind, ExecutionKind and PoolTaskKind (and maybe pointintime and blockfilter too (and EvmRoute before you removed it)). It seems to me the typing is redundant and loses its meaning sometimes.

For instance, a CallPast execution kind always maps onto a CallPast evm kind, an ExecutionKind::CallPresent maps onto EvmKind::CallPresent etc. Then why have EvmKind at all yk? At best the mappings stay consistent but redundant, at worst we eventually have a CallPast execution running on an Inspect EvmKind, passed on a CallPresent pool task.

I tried reworking this a bit in some of the previous PRs I merged but it still is not there, but since this PR is building on top of it I think we should address this first.
I'll try thinking of something but tell me if you have any good ideas of how we should structure this too.

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.

I always got blockfilter and point in time concepts mixed up, so yes, I think it's fair to rethink these (and the other ones too)

For instance, a CallPast execution kind always maps onto a CallPast evm kind, an ExecutionKind::CallPresent maps onto EvmKind::CallPresent etc. Then why have EvmKind at all yk?

yep, it makes a lot of sense.

I tried reworking this a bit in some of the previous PRs I merged but it still is not there, but since this PR is building on top of it I think we should address this first. I'll try thinking of something but tell me if you have any good ideas of how we should structure this too.

sure, i agree with u and think that it's completely fair. I need to think about it, will try to open another pr with an alternative

@gventino-cw gventino-cw Sep 30, 2026 •

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.

did on this pr:
#2721

This branch has not been deployed

No deployments
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.

Make the call past/present evm pools shared with the ability to set concurrency limits for each execution kind

2 participants