Skip to content

Bound concurrent CheckTx on the producer ingest path - #4182

Merged
masih merged 5 commits into
giga-1from
masih/1789479448-bound-checktx-concurrency
Sep 16, 2026
Merged

masih merged 5 commits into
giga-1from
masih/1789479448-bound-checktx-concurrency

Conversation

@masih

@masih masih commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

On autobahn-e2e load tests the validator that receives the load-generator traffic runs at ~28 of 32 Ps while its peers idle at 4-7, and its certify/execute stages fall hundreds of blocks behind receive. Every broadcast_tx RPC call runs the app CheckTx (including ecrecover) on its own goroutine via producer.State.insertTx, so under load there is nothing stopping ingest from taking every P and starving the consensus, data and execute loops.

This change bounds that work. producer.Config gains MaxConcurrentCheckTx (default max(1, GOMAXPROCS/2)), and State.insertTx now takes a permit from a utils.Semaphore of that size around CheckTxSafe, releasing it before the mempool Watch lock is taken. Waiting for a permit is ctx-cancellable, so cancelled waiters return without consuming a slot. Admission, ordering and nonce semantics are unchanged: the permit only covers the CheckTx call. The knob is exposed in the autobahn JSON config as max_concurrent_check_tx (absent means the default, 0 is rejected by Validate). The permit wait is exposed as its own insert phase (in_flight{phase="check_tx_wait"}) plus a check_tx_wait_latency histogram, so queue depth behind the cap is visible alongside the existing CheckTx metrics. BroadcastTxAsync still spawns its own goroutine per call; since the insert path now observes ctx cancellation while waiting for a permit, that goroutine passes context.WithoutCancel of the request ctx so an async broadcast is not dropped once the HTTP handler returns. Replacing the goroutine with a scope owned by the RPC environment remains out of scope here.

To validate on a brandon-autobahn run under the same load: pod CPU of the ingesting validator should drop from ~28 cores to roughly half of GOMAXPROCS plus consensus overhead, executed TPS should hold (or improve, since execute is no longer starved), and next_block{stage="certify"} - next_block{stage="receive"} should stay near 1 instead of drifting into the hundreds. If executed TPS drops with the lag fixed, raise max_concurrent_check_tx in the autobahn config to trade back some ingest parallelism.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 16, 2026, 11:23 AM

@masih
masih marked this pull request as ready for review September 16, 2026 09:28
@cursor

cursor Bot commented Sep 16, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes hot-path tx admission and async RPC behavior under load; mis-tuning the cap could reduce ingest throughput, but mempool semantics beyond CheckTx concurrency are unchanged.

Overview
Caps how many CheckTx calls can run at once on the autobahn producer mempool ingest path, so high broadcast_tx load cannot monopolize CPU and starve consensus/execute.

max_concurrent_check_tx is added to autobahn JSON and wired into producer.Config (default max(1, GOMAXPROCS/2) when unset; 0 is rejected in validation). State holds a semaphore: each insert acquires a permit before CheckTxSafe and releases it after; waiting honors context cancellation without leaking permits. New metrics expose check_tx_wait in-flight phase and check_tx_wait_latency, and check_tx_latency now excludes permit wait time.

BroadcastTxAsync on autobahn passes context.WithoutCancel into the background TryInsertTx so inserts are not aborted when the RPC handler returns while still queued for a permit. Tests cover concurrency bounds, cancelled waiters, and the default resolver.

Reviewed by Cursor Bugbot for commit 8b53310. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.55%. Comparing base (7e3ca24) to head (8b53310).
⚠️ Report is 9 commits behind head on giga-1.

Files with missing lines Patch % Lines
sei-tendermint/config/autobahn.go 50.00% 1 Missing ⚠️
sei-tendermint/internal/rpc/core/mempool.go 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           giga-1    #4182    +/-   ##
========================================
  Coverage   65.55%   65.55%            
========================================
  Files        2081     2078     -3     
  Lines      157460   157160   -300     
========================================
- Hits       103222   103034   -188     
+ Misses      54097    53985   -112     
  Partials      141      141            
Flag Coverage Δ
sei-chain-pr 66.59% <93.33%> (?)
sei-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...i-tendermint/internal/autobahn/producer/mempool.go 95.47% <100.00%> (+5.62%) ⬆️
.../internal/autobahn/producer/metrics/metrics.gen.go 100.00% <100.00%> (ø)
...mint/internal/autobahn/producer/metrics/metrics.go 100.00% <100.00%> (ø)
sei-tendermint/internal/autobahn/producer/state.go 85.56% <100.00%> (+0.78%) ⬆️
sei-tendermint/node/setup.go 63.06% <ø> (ø)
sei-tendermint/config/autobahn.go 71.11% <50.00%> (-0.99%) ⬇️
sei-tendermint/internal/rpc/core/mempool.go 50.27% <0.00%> (-1.14%) ⬇️

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

seidroid[bot]
seidroid Bot previously requested changes Sep 16, 2026

@seidroid seidroid 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.

Bounding CheckTx with a ctx-cancellable semaphore is the right shape and the concurrency tests are solid, but making the CheckTx path observe ctx cancellation for the first time breaks broadcast_tx_async on autobahn validators, which hands TryInsertTx a request-scoped context from a detached goroutine. The new permit wait is also invisible to the producer metrics the PR proposes to validate against.

Findings: 1 blocking | 3 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] The permit wait is unmeasured, which undercuts the validation plan in the PR description. metrics.PhaseCheckTx.Enter() and ObserveCheckTx are now both entered after Acquire returns, so inFlight{phase="check_tx"} saturates at MaxConcurrentCheckTx and checkTxLatency excludes queueing time — waiters sit in no phase at all, and ingest queue depth (the signal that shows whether the cap is set too low) disappears from the dashboards. Adding a PhaseCheckTxWait gauge plus a wait-latency histogram around the Acquire call would keep the existing phase gauges exhaustive and make the new backpressure visible.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] sei-tendermint/internal/rpc/core/mempool.go:67 (BroadcastTxAsync) spawns go func() { _, _ = v.TryInsertTx(ctx, req.Tx) }() with the JSON-RPC handler's request context (hreq.Context() in rpc/jsonrpc/server/http_json_handler.go:68), which net/http cancels as soon as the handler returns. Even before this PR that already silently dropped the tx whenever waitForCapacity was reached (full mempool), and it also violates the module's "don't spawn goroutines with plain go func" convention.

Comment thread sei-tendermint/internal/autobahn/producer/mempool.go Outdated
Comment thread sei-tendermint/internal/autobahn/producer/state.go Outdated
@masih

masih commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

@seidroid review

@seidroid seidroid 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.

The permit-around-CheckTx shape is correct — the permit is released before the mempool lock, so admission/nonce semantics are untouched — and both findings from the previous review are genuinely fixed (BroadcastTxAsync now detaches with context.WithoutCancel, and the wait has its own check_tx_wait phase plus histogram). Two non-blocking notes: the async ingest queue behind the cap is now unbounded and unsheddable, and the new config key has no wiring/validation test.

Findings: 0 blocking | 2 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] No test covers the new config key end to end. sei-tendermint/node/setup_test.go asserts the max_txs_per_secondproducer.Config pass-through (TestBuildGigaConfig_EnabledWithValidators / _NoneMaxTxsPerSecond) but nothing asserts max_concurrent_check_tx reaches Producer.MaxConcurrentCheckTx, and the new Validate bound (0 and > MaxInt32 rejected) is untested, so a dropped field in buildValidatorGigaConfig would silently fall back to the GOMAXPROCS/2 default. The producer-side unit test (TestConfig_MaxConcurrentCheckTxDefault) only covers the in-package resolution.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

// checkTx runs the app CheckTx for tx, holding one of cfg.MaxConcurrentCheckTx permits
// for the duration of the call. Waiting for a permit is cancelled with ctx.
func (s *State) checkTx(ctx context.Context, tx tmtypes.Tx) (*abci.ResponseCheckTxV2, error) {
release, err := s.acquireCheckTxPermit(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The permit wait turns TryInsertTx from fail-fast into an unbounded, now-unsheddable queue. TryInsertTx is documented as "returns error if mempool is full" and BroadcastTxAsync relies on that to be best-effort and non-blocking; with the permit acquired before the mempool state is consulted, every async broadcast parks here first, and since that goroutine is now handed context.WithoutCancel(ctx) nothing sheds it — not a client disconnect, not node shutdown.

Concretely: once arrival rate exceeds MaxConcurrentCheckTx / checkTxLatency (the cap halves the previous service rate, so this threshold is now lower), waiters accumulate one goroutine plus the tx bytes per broadcast with no bound, and the RPC has already returned 200. The CPU starvation this PR fixes is real, but the trade is currently unbounded memory growth instead, with no inserts{result=...} signal distinguishing it — only in_flight{phase="check_tx_wait"} drifting upward.

Bounding the queue on the non-waiting path would keep the fail-fast contract: e.g. a TryAcquire (or a max-waiters count) that returns errMempoolFull for waitIfFull == false, so overload surfaces as inserts{result="full"} and the goroutine exits instead of parking indefinitely. InsertTx (sync broadcast_tx / eth_sendRawTransaction) can keep blocking, since there the caller's ctx still provides backpressure.

@seidroid
seidroid Bot dismissed their stale review September 16, 2026 10:13

Superseded: latest AI review found no blocking issues.

@masih
masih added this pull request to the merge queue Sep 16, 2026
Merged via the queue into giga-1 with commit 8451c7c Sep 16, 2026
67 of 69 checks passed
@masih
masih deleted the masih/1789479448-bound-checktx-concurrency branch September 16, 2026 11:54
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