Keep REST endpoints responsive when bitcoind is slow - #245
Conversation
The REST server dispatched every request through an async fn on the Tokio runtime, but almost every handler is synchronous. The broadcast and package submission handlers make a blocking JSON-RPC call to the daemon, serialized on a single process-wide Mutex<Connection> with a 600 second read timeout. One concurrent request per CPU core against a slow or paused daemon therefore parked every worker thread, and all REST endpoints stopped being served, including fully in-memory ones such as GET /blocks/tip/height. Move the synchronous router onto the Tokio blocking pool, keeping only GET /block-template (the one genuinely async handler) on the runtime. Stop routing client-triggered daemon RPC through the shared singleton connection. sendrawtransaction and submitpackage now run on their own short-lived connection with a 30 second I/O timeout, capped at 8 concurrent calls, so one slow client request can no longer stall indexing or other clients, and failures are reported rather than retried forever. Report the resulting failures as gateway errors instead of the previous 400: 503 when the concurrency cap is reached or bitcoind is warming up, 504 when the daemon does not answer in time. Tunable via DAEMON_PROXY_MAX_CONCURRENCY, DAEMON_PROXY_RPC_TIMEOUT and DAEMON_PROXY_QUEUE_TIMEOUT. New daemon_rpc_proxied metric counts these calls by result.
be7fa5a to
48eb0b5
Compare
| /// connection with a short I/O timeout, and `proxy_limit` caps how many may be in | ||
| /// flight at once. Failures are reported to the client rather than retried. | ||
| #[trace(method = %method)] | ||
| fn request_proxied(&self, method: &str, params: Value) -> Result<Value> { |
There was a problem hiding this comment.
light_mode read paths still unbounded
broadcast_raw/submit_package are fixed here, but --lightmode reads (get_block_txids, get_block_meta, get_block_raw, lookup_raw_txn in schema.rs) still call daemon.getblock_raw/gettransaction_raw via the old request() → shared Mutex<Connection> path (600s timeout, unbounded retry), reachable anonymously via GET /tx/:txid, /block/:hash, etc.
Blast radius is smaller now (blocking pool, not async workers), but the shared-connection bottleneck remains for these. Worth a follow-up applying request_proxied there too?
Those methods are also called internally by the indexer in light_mode, so we'd want to route only the REST-triggered calls through request_proxied, not the indexer's own.
The REST API stops serving all requests when bitcoind is slow or unresponsive — including endpoints that never touch the daemon.
What happens today
Every REST request is dispatched through an
async fnon the Tokio runtime, but nearly all of the handlers are synchronous. They read RocksDB, andPOST /txandPOST /txs/packagemake a blocking JSON-RPC call to bitcoind. A blocking call on an async worker thread ties that thread up for its whole duration.The runtime has one worker per CPU core, so it only takes a handful of concurrent broadcast requests against a slow daemon to occupy all of them. Once that happens nothing else gets polled, and cheap in-memory endpoints like
GET /blocks/tip/heighthang too.It is made worse by all daemon RPC sharing a single connection behind one mutex, with a 10 minute read timeout. A single slow
sendrawtransactionblocks every other daemon user — indexing included — and holds a worker thread for up to those 10 minutes.You can see it locally: pause bitcoind (
docker pause, orkill -STOP), fire onePOST /txper CPU core, then ask for/blocks/tip/height. It hangs.Changes
Run the synchronous handlers on the blocking pool.
handle_requestnow hands the router off totokio::task::spawn_blocking.GET /block-templateis the one genuinely async handler (concurrent callers share a single in-flight fetch) and already offloads its own blocking work, so it stays on the runtime. This keeps the runtime free to answer everything else no matter how long a daemon call takes.Give client-triggered daemon calls their own bounded path.
sendrawtransactionandsubmitpackageare reachable anonymously over both REST and Electrum, so they no longer use the shared singleton connection. Each call now gets a short-lived connection with a 30 second timeout, and a semaphore caps them at 8 in flight. One slow request can no longer stall indexing or other clients, and the number of threads a wedged daemon can park is explicitly bounded. This reuses the same one-shot connection pathgetblocktemplatealready uses.Failures are now reported to the caller rather than retried forever, which is what the old path did.
Report daemon problems as gateway errors. These used to surface as
400 Bad Request, which blames the caller for a perfectly valid request and lets caches and load balancers memoize it. Now:503when the concurrency cap is reached, or bitcoind is still warming up504when the daemon does not answer in timeElectrum clients get a
DaemonErrorfor both.Tuning
The defaults should be fine as-is:
DAEMON_PROXY_MAX_CONCURRENCYDAEMON_PROXY_RPC_TIMEOUTDAEMON_PROXY_QUEUE_TIMEOUTA new
daemon_rpc_proxiedPrometheus counter tracks these calls by result (ok/busy/unavailable/error).Testing
Known gap
In
--lightmode, transaction and block reads still go through the shared connection with the long timeout. Those calls are shared with the indexer, so separating them is a larger change. They no longer block the async runtime, so the problem above is fixed either way, but a lightmode deployment could still fill the blocking pool if bitcoind is down. Worth a follow-up.