fix(nats): stop losing work before it is queued (CLO-4771) - #173
fix(nats): stop losing work before it is queued (CLO-4771)#173levivannoort wants to merge 4 commits into
Conversation
handleError() fired onError and threw but never set status, closed the transport or reconnected, and ProtocolException is not a ConnectionException so no reconnect catch saw it. The connection stayed marked connected over a dead socket, and the next publish() could write into it and return void success -- silent loss reported as a send. Classify the -ERR strings after which NATS closes the connection and recycle it: reconnect when the caller allows it, otherwise mark it disconnected so the next call raises. Authorization and authentication failures also close the connection but are excluded deliberately, because retrying credentials the server just rejected turns a hard failure into a hot loop. checkPings() had exactly one call site, processMessage(), and ran after request() had already written into the dead socket. Drive it from ensureConnected() so the keepalive runs before the write, and add a public tick() so a holder that never reads -- a pooled publisher -- can drive maintenance itself. A connection with no traffic at all still needs a background timer; that design is left open. TcpTransport::ensureConnected() checked !== null but never is_resource(), and isTimedOut() took the resource by parameter rather than re-reading the property, so a close() concurrent with a yielded fread/fgets raised TypeError at TcpTransport.php:181 -- an \Error, which bypasses reconnect entirely. Guard both paths, and apply the same fix to TlsTransport and WebSocketTransport, which carried the identical defect.
Parser::readExactly() passed no timeout, so the payload read inherited whatever deadline the last readLine() left on the stream. A payload split across segments could raise TimeoutException after the header line was already consumed, and processMessage() catches TimeoutException and returns null -- indistinguishable from "nothing arrived". The buffer was then sitting mid-frame, so every later read parsed payload bytes as protocol: permanently desynced, and the dropped frame is often the PubAck itself. Pass the timeout explicitly on every frame-body read, and route the two frame parsers through a guard that marks the parser poisoned and raises ConnectionException instead. That reaches the reconnect path, which builds a fresh parser. The timeout applies per read rather than to the whole frame, because a large payload legitimately arrives across several segments and charging the body to one deadline would fail frames that are making steady progress. KeyValue::create/update and ObjectStore's two guarded meta publishes wrapped js->publish() in catch (\Throwable) and rethrew a semantic verdict, so a stale connection was reported as "you lost the race" -- a conclusion the caller acts on by not retrying. Only a JetStream API rejection carrying err_code 10071 is a real conflict now; transport and timeout errors keep their own identity. ObjectStore::writeVersion also purged on that path, deleting the chunks it had just written, so the purge is now reached only by a genuine conflict: after an ambiguous transport failure the meta publish may well have landed. Consumer::fetch() called $sub->unsubscribe() outside a finally, leaking the inbox subscription on any throw -- and attemptReconnect() re-subscribes every leaked sid, so the leak compounded across a reconnect storm. Move it into a finally and extract the collection loop. The pull request's expires also equalled the client deadline, so a message the server dispatched at the boundary was dropped here after the server had counted the delivery; pull the server expiry in ahead of it.
The check step enforces YieldDataProviderRector, SortCallLikeNamedArgsRector and ThrowWithPreviousExceptionRector; the new tests and Parser::readFrame() were written against none of them.
|
CI note:
That flake is worth its own ticket — three responder round-trips plus a stall window is not reliably under 1.5s on a loaded runner, and the assertion has only ~1.2s of headroom over the stall it is actually testing. Deliberately not touched here. Everything green now, including |
|
@greptile review |
Closes the last finding of CLO-4771. checkPings() had no caller that runs on its own, so a connection with no application traffic was reaped by the server on a timer nobody was driving: NATS pings every 120s and closes after two go unanswered, and the client keepalive only ran while a caller was inside a call. A publisher pooled between publishes is exactly that connection, and the next publish wrote into a dead socket. The mechanism was already there after the previous commit -- Connection has a public tick() -- but nothing called it, and tick() alone was not enough: checkPings() only writes, and it is the read path that clears outstandingPings. Ticking a holder that never reads would therefore march it to maxPingsOut and declare a healthy socket stale, so the keepalive would have caused the outage it exists to prevent. tick() now collects the PONGs it is owed, through a readMessage() split out of processMessage() so the drain does not re-enter the keepalive check and out-ping itself. The clock lives in the queue Swoole adapter, the one layer that owns a scheduler; nats and pools stay free of any dependency on a runtime. Pool::maintain() sweeps idle resources and ticks the ones that can be ticked, discovered by method_exists the same way recover() already probes for reset()/reconnect(). It drains the idle set before ticking rather than popping and pushing one at a time, because adapters choose their own order -- Stack is LIFO, Swoole's channel FIFO -- and on a LIFO adapter the one-at-a-time form hands the same resource straight back and leaves every other one unswept. The split between tick() and maintain() is load-bearing. tick() reads the socket and needs exclusive access; maintain() reaches only what is idle and is safe to call while a consume loop is mid-receive. The adapter sweep calls maintain() and never tick(), so it cannot double-bind the socket a receive loop is blocked on.
All six findings from CLO-4771 — Epic 1 of the NATS queue migration readiness audit. A publish that vanishes never becomes a message, so no consumer metric can see it; these are the paths where that happens.
Two commits: the three P0s (connection death detection), then the three P1/P2s (frame integrity and write semantics).
P0 — Reconnect on a connection-closing
-ERRhandleError()firedonErrorand threw, but never set status, closed the transport, or reconnected — andProtocolException extends NatsException, notConnectionException, soprocessMessage()'s reconnect catch never saw it. The connection stayed marked connected over a dead socket, and the nextpublish()could write into it and return void success: silent loss, reported as a send.Adds a pure
Connection::closesConnection()classifier andrecycleDeadConnection(), which reconnects whenallowReconnectis set and otherwise marks the connection disconnected so the next call raises.Judgment call: authorization and authentication failures also close the connection server-side, but are excluded deliberately — retrying with credentials the server just rejected turns a hard failure into a hot loop. Worth a second opinion.
P0 — Keepalive, now actually driven
checkPings()had exactly one call site,processMessage(), reachable only fromrequest()/wait()/nextMessage(). Nothing in cloud callswait(), and there was no timer — so it also ran afterrequest()had already written into the dead socket.It is now driven from
ensureConnected(), so the keepalive runs before the write, andConnection::tick()is public for a holder that never reads.tick()alone was not enough, and this is the part worth reviewing.checkPings()only writes; it is the read path that clearsoutstandingPings, and a pooled publisher has none. Ticking such a connection would therefore march it tomaxPingsOutand declare a perfectly healthy socket stale — the keepalive causing the outage it exists to prevent.tick()now collects the PONGs it is owed, through areadMessage()split out ofprocessMessage()so the drain does not re-enter the keepalive check and out-ping itself.The clock lives in the queue Swoole adapter, the one layer in the stack that owns a scheduler;
natsandpoolsstay free of any dependency on a runtime.Pool::maintain()sweeps idle resources and ticks the ones that can be ticked, discovered bymethod_existsthe same wayrecover()already probes forreset()/reconnect()— so no change to theAdapterinterface, and no breaking bump for external adapters.Two things in there are load-bearing:
tick()vsmaintain().tick()reads the socket and needs exclusive access;maintain()reaches only what is idle and is safe while a consume loop is mid-receive. The adapter sweep callsmaintain()and nevertick(), so it cannot double-bind the socket a receive loop is blocked on.Pool::maintain()drains before ticking rather than popping and pushing one at a time. Adapters choose their own order —Stackis LIFO, Swoole's channel FIFO — and on a LIFO adapter the one-at-a-time form hands the same resource straight back and leaves every other one unswept. Caught by the test, which found exactly that.This closes the finding for any Swoole host. A plain-TCP host still calls
tick()itself, which is inherent: nothing in a library can keep an idle socket alive without a clock, and the clock has to come from the host.P0 — Closed stream resources
TcpTransport::ensureConnected()checked!== nullbut neveris_resource(), andisTimedOut()took the resource by parameter instead of re-reading$this->stream. Aclose()concurrent with a yieldedfread/fgetsraisedTypeErroratTcpTransport.php:181— and because that is an\Error, not aConnectionException, it bypassed reconnect entirely.ensureConnected()now checksis_resource();isTimedOut()and a newisAtEof()re-read the property.Scope note: the ticket names TcpTransport, but TlsTransport and WebSocketTransport carried the identical defect — same
=== nullcheck, same!feof($this->stream)inisConnected(). All three are fixed; fixing one would have left TLS and WebSocket exposed while looking handled. SwooleTransport uses a SwooleClientobject and is unaffected.P1 — Parser fails closed mid-frame
readExactly()passed no timeout, so the payload read inherited whatever deadline the lastreadLine()left on the stream. A payload split across segments could raiseTimeoutExceptionafter the header line was consumed — andprocessMessage()catchesTimeoutExceptionfirst and returnsnull, indistinguishable from "nothing arrived". The buffer was then sitting mid-frame, so every later read parsed payload bytes as protocol: permanently desynced, and the dropped frame is often the PubAck itself.The timeout is now passed explicitly on every frame-body read, and the two frame parsers run through a guard that marks the parser poisoned and raises
ConnectionException— which reaches the reconnect path, andconnectToServer()builds a freshParser.Design note: the timeout applies per read, not to the frame as a whole. A large payload legitimately arrives across several segments, and charging the whole body to one caller deadline would fail frames that are making steady progress — a regression risk the stricter reading would have introduced.
P1 — Transport failures are no longer rewritten as CAS conflicts
KeyValue::create/updateand ObjectStore's two guarded meta publishes wrappedjs->publish()incatch (\Throwable)and rethrew a semantic verdict, so a stale connection was reported as "you lost the race" — a conclusion the caller acts on by not retrying.Only a JetStream API rejection carrying
err_code10071 (newJetStream::ERR_WRONG_LAST_SEQUENCE) is treated as a conflict; transport and timeout errors keep their own identity, and other JetStream API errors rethrow as themselves rather than being relabelled.ObjectStore::writeVersionalso calledpurgeChunks()on that path, deleting the chunks it had just written. The purge is now reached only by a genuine conflict — after an ambiguous transport failure the meta publish may well have landed, so deleting its chunks is the worst available option.P2 — Fetch inbox subscription leak
Consumer::fetch()called$sub->unsubscribe()outside afinally, leaking the inbox subscription on any throw — andattemptReconnect()re-subscribes every leaked sid, so the leak compounded across a reconnect storm. Moved into afinally, with the collection loop extracted tocollectBatch().The pull request's
expiresalso equalled the client deadline exactly, so a message the server dispatched at the boundary was dropped client-side after the server had already counted the delivery, burning amaxDeliverattempt for nothing. The server expiry is now pulled in ahead of the client deadline.Verification
nats)pools)queue)queue)Headers.php:98) — verified by stashing and re-running on cleanmainbin/monorepo validateThe new tests were each checked against the unfixed code rather than just passing against the fix:
finally→ 2 failures, with 5 repeated fetches leaving 5 leaked subscriptionsTypeErroron a closed resource before the guardIncidental, not fixed here
The reconnect jitter calls
lcg_value(), deprecated in PHP 8.4, while the package declaresphp: >=8.1. Pre-existing; these tests surfaced it. Out of scope — the tests setreconnectJitter: 0.0, which also cut the unit suite from 2.05s to 0.008s. Worth its own ticket.Update — the keepalive finding is now closed
The last commit closes finding 2, which this PR originally left open pending a design decision. The decision was adapter-driven tick: the queue Swoole adapter owns the interval,
poolsgains amaintain()sweep, andnatsgains the PONG collection that makes ticking safe. Rationale and the two rejected alternatives are in the section above.The e2e for it asserts on reconnect count, not on the publish succeeding. A keepalive that pings without ever reading the replies still ends with a working publish —
checkPings()quietly rebuilds the connection once the budget is spent — so "the publish worked" cannot tell the two apart. What separates them is that the broken version gets there by churning through reconnects of a connection that was healthy all along: 4 reconnects before the fix, 0 after.CLO-4749("CDN purge dies on an idle-closed pooled connection") is the same failure class in a different package, andPool::maintain()is the mechanism that covers it too.Stacked follow-ups