Repository navigation
bugfix: new connects took the idle connection handed to a queued connect, which then went past the pool size. - #2544
Conversation
| item = conn_op_ctx->item; | ||
| conn_op_ctx->item = NULL; | ||
|
|
||
| if (item != NULL) { |
There was a problem hiding this comment.
One small thing I noticed while writing the tests. It's not a bug, just something you may or may not want to change.
A waiter that gets resumed with an empty slot (because someone close()d rather than setkeepalive()d) always opens a new connection when it runs. It doesn't look at cache, because connect_helper() is called with resuming = 1. If someone else puts a connection back into cache in the same cycle, before the waiter runs, that idle connection just sits there while the waiter dials a new one.
A quick way to see it, with pool_size = 2, backlog = 1:
- A and B are connected. W is queued.
a:close()hands an empty slot to W.b:setkeepalive()in the same cycle: nobody is waiting any more, so B's connection goes tocache.- W runs and gets reused 0 (a new connection), and the next
connect()gets B's connection (reused 1).
The pool never goes over pool_size. W's slot and B's cached connection are both counted, so it's only one extra connection opened at that moment. If you think it's worth it, the empty-slot path could take the head of cache first, and give the spare slot back with release_slot(spool, NULL) (which wakes the next waiter, or decrements connections). I'm also fine leaving it as is.
Port the direct handoff of openresty/lua-nginx-module#2544 (b965a88, fbbcf41 and 3e21572) to the stream module, replacing the reserved queue and the next_connect_op cursor. A pool counts the slots it handed out, to active sockets, cached idle connections and posted connect operations, in connections, and the operations still queued, which hold no slot, in waiting; the backlog check is on waiting. Posted operations move to their own posted_connect_op queue. When setkeepalive() returns a connection while someone waits, the connection is attached to the head waiter instead of going to the cache, and the waiter takes it when its resumption runs. Every place that frees a slot goes through ngx_stream_lua_socket_tcp_release_slot(), which resumes the next waiter with the slot and the connection, if any, or puts the connection in the cache or decrements the count. As in #2544, a queued or posted socket stays busy connecting and its pool key is written only once the busy checks pass; a failed connect closes a partly set up peer and releases its slot once; a connection made before the pool existed only replaces an idle connection in a full pool; and a posted operation cancelled by a light thread kill, the end of its timer or session, or a request cleanup releases its slot.
Port the tests of openresty/lua-nginx-module#2544 to the stream module: tests 65 to 88 in t/068-socket-keepalive.t and a new t/196-socket-keepalive-queue.t. The http cases that end a subrequest before its queued or posted connect runs use a timer that exits instead (tests 74 and 85). The internal redirect cases have no stream counterpart; test 81 resumes a waiter in the preread phase instead, and t/196 runs its handoff, cancellation and timeout checks in the preread and content phases and keeps the fixed-seed stress case.
Hand idle connections and free pool slots directly to queued connect operations. Track waiting and posted operations separately so new connects cannot take a connection assigned to a waiter or exceed the configured pool size. Reuse cached peers before opening a connection when a resumed operation owns an empty slot. Release or transfer each slot once on cancellation, timeout, connection failure, request cleanup, and pool shutdown. Keep queued sockets busy until their connect completes and preserve their pool on rejected concurrent operations. Add regression and stress coverage for handoff, spare capacity, failed connects, coroutine cleanup, and shutdown across request phases and protocols. Document the longer stress run and update the shared dictionary entry count for the new lua-resty-core compare_expire method. Co-authored-by: lijunlong <lijunlong@openresty.com>
72597fc to
9661d95
Compare
I hereby granted the copyright of the changes in this pull request
to the authors of this lua-nginx-module project.
With a connection pool
backlog, the pool can hold far more thanpool_sizeconnections.setkeepalive()puts the connection back in the pool, wherespool->connectionsstill counts it, and callsngx_http_lua_socket_tcp_resume_conn_op(), which posts the resumption of the next queued operation so that it takes this idle connection. Aconnect()made before that event runs callsngx_http_lua_get_keepalive_peer()first and takes the idle connection. When the resumed operation runs,ngx_http_lua_get_keepalive_peer()finds nothing, and since a resumed operation skips the backlog check inngx_http_lua_socket_tcp_connect_helper(), it opens a new connection. Each time this happens the pool holds one more connection thanpool_size, up topool_size + backlog.This existed before #2542, but there only one operation was posted at a time, so it was rare. Since #2542 every freed slot posts its own operation, which leaves many more windows for a new
connect()to take a connection handed to a waiter. One worker withpool_size = 100, backlog = 500, serving a closed-loop load of 550 and 2000 concurrent requests, each making one cosocket query to a local backend over a unix domain socket and putting the connection back withsetkeepalive(), held up to 310 to 536 connections to the backend in content and rewrite handlers. An earlier build without #2542 held up to 111.The pull request has twelve commits. The design that lands is the direct handoff from b965a88, written by the maintainer on top of the first five. The earlier commits are kept as history: the list below says, for each one, whether it was replaced, kept as a rule, or is a separate fix.
Final design
After b965a88 a pool keeps two counts:
connectionsis the slots handed out: active sockets, cached idle connections, and posted connect operations.waitingis the operations still queued inwait_connect_op, which hold no slot.The backlog check is
waiting >= backlog. Posted operations move to their ownposted_connect_opqueue, which replaces thenext_connect_opcursor.When
setkeepalive()returns a connection while someone waits, the connection does not go tocache. It is attached to the head waiter (op->item, withitem->ownerpointing back), and the waiter takes it withngx_http_lua_socket_tcp_reuse_peer()when its resumption runs in its own request. New connects look atcacheonly, so they cannot take it.Every place that frees a slot goes through
ngx_http_lua_socket_tcp_release_slot(), whether or not a connection comes with it:setkeepalive(),finalize(), the keepalive close handler, cancellation of a posted operation, a failed connect, and an aborted resolve. If someone waits, it resumes the head waiter with the slot and the connection, if any. Otherwise it puts the connection incache, or decrementsconnectionswhen there is no connection.A connection closed while it waits for its owner only clears the owner's
item; the owner then connects in its slot.An operation resumed with an empty slot, with no connection handed to it, first takes the head of
cachewhen it runs. If it gets one, it gives its spare slot back throughrelease_slot(). Otherwise it connects in its slot (12e9595).Commits
connect()skipped every idle connection while any operation waited. Replaced by the second commit.reservedqueue, out of reach of new connects. Replaced by b965a88.waiting.release_slot()without a connection, and test 65 stays.connect()on it is rejected before it can change the pool key.t/196-socket-keepalive-queue.t, which covers handoff, cancellation and timeouts in rewrite, access and content, and has a fixed-seed stress case. Also adds tests 66 to 84 tot/068.cachewhen it ran, becauseconnect_helper()is called withresumingset. If a connection was put back in the same cycle, with nobody left waiting, the operation opened a new connection while that idle one stayed unused. The pool stayed withinpool_size, but opened one connection it did not need. The operation now takes the head ofcachefirst and gives its spare slot back. Test 92: withpool_size = 2,a:close()hands an empty slot to w, thenb:setkeepalive()runs before w does. w now gets b's connection (reused 1), and exactly one more connection fits. If you would rather keep the old behaviour, this commit can be dropped on its own.Approaches considered
The overflow has two ingredients: a new
connect()can take an idle connection meant for a posted operation, and a resumed operation that finds none opens a new one without the backlog check. Each approach below removes one of them in a different way.connect()while operations waitconnect()connect()can time out next to onecacheand the posted operations togetheritemandowner), so no special queue is needed. An earlier version of this table rejected F in the form "set up the connection on the waiter's upstream insetkeepalive()", outside the waiter's request. b965a88 avoids that: the connection is attached only when the operation runs in its own request.Measured on the load above at 550 concurrent requests in content and rewrite handlers, 6 seconds per run, before b965a88. The final design was not measured on this load:
too many waiting connect operationsB was measured in one run per handler, D in two; out of 68,000 to 222,000 requests per run, both stay at or below 0.15% of the requests, and on my machine throughput itself varied several times over between identical runs, so I would not read a difference between them. The much higher count of A comes from its new connects queueing behind waiters while idle connections sit unused.
Tests added to
t/068-socket-keepalive.tby the first four commits:pool_size = 1, one waiter,setkeepalive()and a newconnect()in the same cyclewaiter: reused 1,newcomer: reused 2waiter: reused 0,newcomer: reused 1)pool_size = 2, two connections come back with one waiter queued, newconnect()with a 200ms timeoutnewcomer: reused 1newcomer: timeout)connect()w2: reused 1,newcomer: reused 2w2: timeoutw2: reused 0)killed: 1,w2: reused 0w2: timeout)Tests 61 to 64 passed over HTTP/1.1 and HTTP/2, and under valgrind with no errors, on the reserved queue.
Test 65 needs the keepalive timer and the cancellation to come before the posted resumption in one cycle. To set that up:
coroutine.yield(), so that it runs after the entry thread has startedngx.sleep(0.002).setkeepalive(1)and moves the clock on withngx.update_time().Test 65 is skipped over HTTP/3, where the request itself runs from posted events and that order cannot be set up.
With all twelve commits, tests 53 to 92 and
t/196pass on my machine over HTTP/1.1 and HTTP/2. The other tests in the file fail or pass the same with and without the change. Those failures come from my local setup: aServerheader of a different length, some services I do not have, and127.0.0.2:12345being refused at once instead of timing out.With the reserved queue (second to fourth commits), the load above held exactly 100 connections in every case. A few requests near
pool_size + backloggottoo many waiting connect operations: 17 to 125 out of 68,000 to 132,000 requests in 6 seconds at 550 concurrent requests. They were rejected because a posted operation and the idle connection it was about to take were both counted until its event ran. The final design counts queued operations apart from slots, which should remove that double count by construction. I have not rerun the load on it.Tested against nginx 1.31.8 in an OpenResty build with this module at current master.
Additional tests for the direct handoff (6516e3f)
These cover paths of the direct handoff in the later commits that no test reached or asserted. I found them by reading the tests against the code and by a gcov run of
t/068-socket-keepalive.tandt/196-socket-keepalive-queue.t.setkeepalive: connection in dubious state,waiter: reused 0, then exactly one connection fits in the poolconnection refusedwithout sitting out the connect timeout, then exactly one connection fitsreused 0) and a second connect times outreused 1connect()with another pool on a socket that is busy readingsocket busy reading, andsetkeepalive()still uses the original pool (reused 1)reused 1) and one more times outngx_http_lua_socket_shutdown_pool_helper()frees both, and the Lua VM is closed afterwards (a__gcmarker logs fromlua_close())Each test fails when the branch it covers is broken by a one-line change. Examples:
ngx_http_lua_socket_tcp_finalize()(86, 87);waiting--for a cancelled queued operation (88);The tests passed five runs out of five over HTTP/1.1 and over HTTP/2, and under valgrind with no errors from this module. I did not run them over HTTP/3. With them added, the full file fails the same environment-dependent tests as before on my machine, which has no memcached, and
t/196passes.Some paths remain uncovered:
ngx_http_lua_socket_tcp_conn_op_ctx_cleanup()looks unreachable from a test: its cleanup is registered and freed within the same synchronous run of the phases.ngx_terminateonly between cycles.ctx == NULLchecks and the out-of-memory branches have no tests.The stream counterpart is openresty/stream-lua-nginx-module#418, now ported to the same design: the direct handoff and its fixes, 12e9595, and the tests, including
t/196.Contributed by AxisNow.