Repository navigation
Add experimental per-request timeout recovery for reads under fiber schedulers - #85
Conversation
| it 'returns the same results as default reads over SSL' do | ||
| memcached_ssl_persistent do |_, port| | ||
| client = Dalli::Client.new("localhost:#{port}", ssl_context: CertificateGenerator.ssl_context, | ||
| raw: true, compress: false) | ||
|
|
||
| assert_same_results(client) |
There was a problem hiding this comment.
Kinda irrelevant for our purposes tbh but this test is hanging in the ruby 3.2 and 3.3 test matrices in CI.
My robot did some smoke testing and came back with this:
I ran the same sequence on main with default reads only (flush, set, a 700 KB set, then set_multi of 40 × 3 KB, twice on one SSL connection). It hung in the same frame in 1 of 6 runs. On this openssl version, SSLSocket#sysread doesn't apply IO#timeout, so socket_timeout never fires and the wait has no limit. I didn't find out why memcached stops replying.
It's possible that the mn was never received by the server for that one flake, so the pipelined request was never registered as finished and memcached never relied back. You could probably just make this test skip on OpenSSL::VERSION < '3.3'
#86) A 40 x 3KB set_multi over TLS can stall memcached waiting for MN, and on Ruby < 3.4 the openssl gem ignores IO#timeout, so the SSL parity test hung until GitHub's 6h job limit. Load fixtures with individual sets and add a 15 minute job timeout so future hangs fail fast.
The reader is always discarded after an IO::TimeoutError (error_on_request! -> close), and the assertion only held because read_nonblock leaves the buffer intact on :wait_readable for plain IO.
Problem
socket_timeoutis applied to sockets throughIO#timeout. Under a fiber scheduler, that timeout is a scheduler timer. The timer starts when a read starts to wait, which is right after Dalli sends the request.Fiber schedulers run an event loop, and one pass of the loop works like this:
Suppose a fiber that this pass resumed keeps the thread for longer than
socket_timeout. A reply that arrives during that time is not seen until the next poll, but the reply's timer runs first, in step 3. The read raisesIO::TimeoutErroreven though the reply is already in the socket.Dalli then handles this as a network failure. It closes the socket, which discards the reply. With
socket_max_failuresit also marks the server down, so requests duringdown_retry_delayfail as well. From the caller's side, a slow neighbouring fiber looks like a server timeout.The exposure depends on the selector backend. The tests below reproduce it with Async on every io-event backend:
Thread::Queue,ConditionVariable, promises)sleep)Rescuing
IO::TimeoutErrorand reading again is not safe. BeforeIO#getsandIO#read(n)wait, they move bytes from Ruby's internal read buffer into the result string they are building. When the exception propagates, that string is discarded. A second read would then start in the middle of a response and get out of step with the protocol.Solution
This change adds an opt-in request option,
exp_async_timeout_recovery: true. It uses the existing request-options plumbing, so a single request can opt in:Base#requestreads the option and passes it toConnectionManager#start_request!. The flag is cleared when the request finishes or aborts, or when the connection closes.ConnectionManager#read_line,#readline,#readand#read_exactread through the newDalli::Protocol::TimeoutRecoveryReader:read_nonblockinto a connection-local binary buffer and slices lines and values out of it.wait_readable(socket_timeout). With an explicit timeout, a scheduler returnsfalseinstead of raising.Fiber.blocking { wait_readable(0) }, which polls the socket directly instead of going through the scheduler. If data is there, the read continues. If not, it raisesIO::TimeoutError, which takes the same rescue path and failure policy as today.The following are unchanged:
IO#timeoutget_multipath used with multiple servers or a blockMemory and allocations
read_nonblockdoes not use Ruby's internal IO read buffer. A connection that serves only flagged requests therefore never allocates Ruby's own 8 KiB read buffer.IO#getsandIO#read. The read loops avoid block-based iteration, which would allocate on every call, and a test checks the per-line allocation count.Benchmark setup: Linux, Ruby 4.0.6 with YJIT, memcached 1.6.41 on a UNIX socket, async 2.46.0, io-event 1.21.1. Each row shows the median per operation and objects allocated per operation.
get100 Bget700 KBget_multi40 × 3 KBget100 Bget700 KBget_multi40 × 3 KBget100 Bget700 KBget_multi40 × 3 KBOn macOS without a scheduler,
get_multiof 40 × 3 KB values was slower with the option: 120 µs default against 159 µs. A profile shows that this time is spent in socket calls, not in the reader's Ruby code.Tests
test/protocol/test_timeout_recovery_reader.rb(no memcached needed):IO#getsandIO#readresults and encodings, including at EOFIO::TimeoutErrorwhen nothing arrives before the timeouttest/integration/test_async_timeout_recovery.rb:get(hit, miss and 700 KB value),get_multiwith misses,get_multi_with_status,get_with_status,get_cas,gat,touch,fetch,incr,setanddelete.get_multi.Results:
--enable-tls,IO_EVENT_SELECTOR=URingas in CI): the full suite ran 643 tests with 0 failures and 0 errors.Rollout
The option is experimental and off by default, so callers can roll it out gradually. A follow-up will either make this the default read path or remove the option.