Conversation
…osed Session.close() set closeInitiated, interrupted the publisher thread and joined it, and everything still in sendQueue went with it. The StopMsg published afterwards still went out, so the peer read an orderly close with no sign that anything was missing. PR OpenIdentityPlatform#919 recorded this as a known limitation and named the fix; this takes the first of the two options it listed. The queue is now sent from close(), after the join - the publisher is gone, so the closing thread owns the socket - and before the StopMsg, so that message stays last on the wire. A session which already failed is left alone, for the reason the StopMsg is. The drain is bounded by DRAIN_BUDGET_MS, 5 s, the value DSRSShutdownSync.REPLICA_OFFLINE_GRACE_PERIOD already spends waiting for one of these messages to be forwarded: close() is on shutdown paths, and an unbounded drain would hold the thread shutting the server down for as long as a stalled consumer stays stalled. What it gives up on is logged rather than dropped in silence. SessionPublisherDrainTest also pins which end of a session could ever lose a queued message: only ServerHandler starts a session's publisher, so a change a directory server publishes is written to the socket before publish() returns. That rules the send queue out as the explanation of OpenIdentityPlatform#963. Refs OpenIdentityPlatform#963
The case failed on three ubuntu legs of CI and on nothing else: the peer had received 2025, 2134 and 1306 of the 3000 messages. The give-up warning appeared in none of those logs, so the drain had written the whole queue and the loss was under the write. The case had no reader on the closing side, so that side reached close() with inbound bytes nobody had read, and a close in that state ends the connection with a reset rather than a FIN - which discards what the peer has not read yet, the drained queue included. Measured with a bare socket pair, 8 MiB to a peer reading behind the writer, the only difference being whether the closing side drained its own inbound: linux 6.12/jdk11 53.4% against 100%, macos 15.7/jdk26 98.3% against 100%. That spread is why every macos and windows leg passed. The server has no such condition where the drain does anything: ServerHandler.shutdown() closes the session at :946 and joins its ServerReader only at :966, and the paths which close without a live reader are the ones the drain skips anyway - Session.run() after a send threw, and ServerReader's finally on an error, both of which leave sessionError set. So the case keeps a reader too, with its soTimeout lifted, receive() handing a read timeout to setSessionError() being enough to skip the drain and test nothing. The read of the peer end now reports what ended it. Run on linux/jdk11 with that reader commented out, the case fails with "the peer received 2873 of the 3000 messages published; the read ended by java.net.SocketException: Connection reset", and passes with it in. Refs OpenIdentityPlatform#963
|
@maximthomas this was in draft while I chased its own CI failure. It is back for review, and the The first run failed on three ubuntu legs and on nothing else - 32560 tests, one failure, this The warning this change adds for a queue it cannot send appeared in none of those logs, which is Measured with a bare socket pair, 8 MiB written to a peer reading behind the writer, the only
That spread is the whole reason the case passed on every macos and windows leg, and on my machine,
The server does not have that condition where the drain does anything, which is why the change to Two things I would rather you heard from me than found:
On the part of this which is yours: the send-queue candidate you raised on #963 is ruled out as the |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The drain sits exactly where a close can lose something, and the description says what it does not fix.
Session.close():217-219drains between the publisherjoin()and theStopMsg, so the stop stays last on the wire, and thelocalSessionError == nullguard reuses theStopMsg's own skip.theSessionOfADirectoryServerBrokerHasNoPublisherThreadpins the onesession.start()in main (ServerHandler.java:362), which is what rules the #963 mechanism out for a broker.- The drain-removed mutant is red here too:
aSessionWithAPublisherThreadSendsWhatIsStillQueuedWhenItIsClosedfails with "the peer received 176 of the 3000 messages published" (local failsafe run at 3dc6939, drain call deleted fromclose(), reader in place).
issue (blocking): The drain is not the only writer of the socket after the join: a ServerWriter's direct send can land between two drained messages, and an RS peer then discards the older ones.
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/Session.java:263-278, :219, :243-244
sendWhatThePublisherLeftQueued() calls send() per buffer and send() takes publishLock per message (:441); nothing is held across the loop. ServerHandler.shutdown() (ServerHandler.java:940-966) sets shutdownWriter, closes the session at :946, and joins the ServerWriter only at :966; take() (:989-994) returns the UpdateMsg it already holds as soon as acquirePermitInSendWindow()'s 500 ms loop (:1072) sees shutdownWriter, and ServerWriter.run() (ServerWriter.java:99-126) calls session.publish() with no shutdown check — isRunning is false after the join, so that is a direct send(). HeartbeatThread.shutdown() (:950) also runs after the close. So the comment at :219 ("this thread is the only one left writing this socket") and the javadoc at :243-244 ("the socket is this thread's alone") state an invariant the code does not have.
The road is the one the drain exists for: a peer whose send window is exhausted has the publisher blocked in output.write() with a backlog queued and the writer waiting for a permit with a newer UpdateMsg in hand. When the peer resumes reading at close, join() returns, the drain writes the backlog at the peer's pace (up to 5 s), and within 500 ms the writer's newer CSN is written between two older drained ones. On an RS peer LogFile.appendWouldBreakKeyOrdering() (LogFile.java:281, key <= newest) then drops every later-arriving older record at debug level, the peer's ServerState is already past them so no reconnect re-serves them, and reportQueueNotSent() never fires. Not a regression — BASE dropped the whole queue and let the same direct send move the peer's state past the loss — but the premise and the guarantee this PR states do not hold there.
Holding publishLock across the loop restores the invariant the comment claims: the writer's and the heartbeat's direct sends wait for the drain and land after it, in order; the IOException arm and the budget return release it in the finally.
final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_BUDGET_MS);
byte[] buffer;
publishLock.lock();
try
{
while ((buffer = sendQueue.poll()) != null)
{
// ... unchanged: deadline check, send(buffer), IOException arm ...
}
}
finally
{
publishLock.unlock();
}Then :219 and :243 can say that publishLock is held for the whole drain, so the ServerWriter and heartbeat sends which can still reach publish() wait for it — instead of claiming a single writer.
issue (non-blocking): localSessionError is a snapshot from before the join, so a publisher whose write fails during the join leaves the drain running on a failed socket.
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/Session.java:170-174, :217
close() copies sessionError under stateLock before interrupt()/join(). A publisher inside send() which gets the reset records it (run(), :633), its own close() returns on closeInitiated, join() returns, and :217 tests the stale null: the drain's first write fails at once on the errored socket and reportQueueNotSent() names the drain's own EPIPE rather than the reset the publisher recorded; the StopMsg then goes to the same socket (the BASE shape). One misleading WARN, no extra hang — this road needs a failed write, not a blocked one. The comment at :207-210 says this road is skipped; re-reading the error after the join makes it so.
try { interrupt(); join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
synchronized (stateLock)
{
localSessionError = sessionError;
}suggestion (non-blocking): The between-messages budget runs under the domain lock in stopServer(handler, false), and its javadoc says two things the code does not do.
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/Session.java:250-255, :269, opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java:1135, :1214
stopServer(h, false) takes lock() at :1135 and holds it across unregisterServerHandler() → sHandler.shutdown() (:1214) → ServerHandler.shutdown():946 session.close() → the drain (callers: :925/:1000 after a failed ack, :1060 stopReplicationServers, ServerHandler.doStop():1254, ServerWriter's finally :165, :1644, :1679-1680, :2814). A slowly reading peer with a backlog at close now holds the domain lock for up to 5 s plus one write, where BASE paid the publisher's in-flight write plus the StopMsg; a handshake on that domain meanwhile fails lockDomainWithTimeout() (ServerHandler.java:804-810, tryLock(3000 + rand*1000)) with WARN_TIMEOUT_WHEN_CROSS_CONNECTION and the broker retries. The 5 s is the PR's choice and I would not change it here; it is worth one sentence in the javadoc that the hold is under the domain lock on the non-shutdown road.
The javadoc: :250 "The budget is what bounds a close of a session whose peer has stopped reading" is contradicted by :253-255 of the same paragraph — a peer which has stopped reading is exactly the one whose single write blocks past the budget (until the peer reads or TCP gives up, ~15 min at Linux defaults for a black-holed host; a state which at BASE wrote only the StopMsg and returned, though BASE hung the same way whenever the publisher itself was mid-write). And :251-252 "a peer which is gone pays none either - the write fails at once" holds only for a peer which answers RST. Say what holds:
* The budget bounds how many messages a close spends on a peer which is reading slowly. A
* peer which is reading pays none of it; a peer which answers with a reset pays one failed
* write. It is checked between messages, so a single write which blocks past the budget still
* runs to completion - for a peer which has stopped reading, or vanished without a reset, that
* is for as long as TCP keeps the connection alive: bounding it needs a non-blocking socket,
* which this session is not.question (non-blocking): Was WARN with a stack trace intended on the peer-closed road too, or only on the budget road?
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/Session.java:279-284, :286-296
A DS which stops while the RS's publisher for it has a backlog: its StopMsg reaches ServerReader, the finally (ServerReader.java:228) calls session.close() with sessionError null, the drain's first writes are absorbed until the DS's close answers with a reset, and the IOException arm reports "N message(s) ... the peer was not told about them: " at WARN. For a DS peer those updates are re-read from the changelog on reconnect (its ServerState never covered them), so the line reads as a loss the protocol recovers from, once per DS per RS on a rolling restart under load; a real loss (a ReplicaOfflineMsg forward to a peer RS) reads the same. Not run — the road is by read. If only the budget road was meant to alarm: on the IOException arm log the exception's class and message, and say a peer which reconnects re-reads them — or INFO there and WARN kept for the budget arm, the one nobody recovers from.
suggestion (non-blocking): The IOException arm of the drain and the count it reports are pinned by no test, though that arm is drivable.
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/Session.java:279-284, opendj-server-legacy/src/test/java/org/opends/server/replication/protocol/SessionPublisherDrainTest.java:174-245
The description says the give-up path has no test because it needs a peer which never reads — that is the budget arm. The IOException arm needs a peer which has closed, which the fixture can do. None of the three cases closes the receiver before a sender with a non-empty queue, and none reads the log: deleting the return at :283 (one failed write and one WARN per remaining buffer on every close of a dead peer) or the + 1 at :282 survives green, by construction.
Pin: a fourth case — publish N messages on a sender whose peer reads nothing, close the receiver's socket, then close the sender; capture the error log (ReplicationTestCase's helper at :948) and assert exactly one line carrying "was closed with" whose count equals the messages the peer never received (N minus the frames read off the peer's socket before it closed; count the writer's records once — it holds every line twice).
suggestion (non-blocking): The drain case pins the drain through a producer/publisher backlog, not the full socket buffer its comment claims, and asserts neither that precondition nor the order it says it pins.
opendj-server-legacy/src/test/java/org/opends/server/replication/protocol/SessionPublisherDrainTest.java:218-245, :171-173, :74-78
The mutant is red (176 of 3000 received), so the pin holds — but not for the reason at :220-221. Measured on this box, a TCP_NODELAY loopback pair with nobody reading absorbs ~545 KiB; 3000 TLS-wrapped DeleteMsg frames (~150 B each, ~440 KB) sit in the kernel and the publisher never blocks in its write. What leaves ~2800 messages in sendQueue at close is publish() (encode + offer) outrunning the publisher thread (TLS write + flush per frame) — a race the case neither asserts nor bounds, so a faster publisher or a slower producer shrinks it toward zero with no red to say the case stopped pinning anything. drained.endedBy is carried into the assertion text ("the read ended by a StopMsg") and asserted nowhere, so 3000 deletes followed by an exception in place of the StopMsg still passes.
// right before submitting close(): the case pins nothing unless the publisher is behind
final int queuedAtClose = ((java.util.Queue<?>) sendQueueField.get(sender)).size();
assertThat(queuedAtClose).as("messages still queued when close() ran").isGreaterThan(0);
// ...
assertThat(drained.endedBy).as("the StopMsg stays last on the wire").isEqualTo("a StopMsg");Or make the precondition by construction: a peer which reads nothing until close() is submitted, so every message is in the queue. Either way, rewrite :220-221 to say the producer outruns the publisher rather than that the socket buffers fill.
nitpick (non-blocking): The comment the description names as the pointer to this limitation still describes the old close().
opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerShutdownSyncTest.java:371-376
"close() discards whatever is still in its send queue without draining it, which is the limitation issue #919 recorded. If this is the only assertion which fails, that window is the explanation" — the file is not in the diff. Say the close now drains within DRAIN_BUDGET_MS, and that a red there means the budget ran out (the WARN this PR adds is the thing to look for) or the interleave of the blocking issue above.
Refs #963. Two things which came out of investigating that issue, and which belong together
because the second is what the first ruled out.
The tests: which end of a session can lose a queued message
Session.publish()has two branches, and which one a message takes decides whether a close canlose it:
isRunningis set insiderun(), so it is true only where something calledSession.start()-and in the whole server that is one place,
ServerHandler.java:362.ReplicationBrokerneverstarts its own. So the replication-server end of a session has a publisher thread and the
directory-server end does not.
SessionPublisherDrainTestpins that, because it is what decides where a close can lose anything:theSessionOfADirectoryServerBrokerHasNoPublisherThread- the session a real broker publishesits changes on is still
Thread.State.NEWafter the handshake;aSessionWithNoPublisherThreadHasReachedTheWireWhenPublishReturns- a change published and thesession closed at once still reaches the peer, and the session's own thread never ran, so
publish()is what wrote it. Without that second assertion the case would pass on eitherbranch: a publisher thread usually outruns a close for a single message, which is measured
rather than assumed - starting the publisher in that case is what makes it fail;
aSessionWithAPublisherThreadSendsWhatIsStillQueuedWhenItIsClosed- the case for the changebelow.
drain()stops at theStopMsg, so the size it asserts pins the order too.What this says about #963. The candidate raised there - that the
AddMsgofReSyncTest.testResyncAfterRestorewas dropped bySession.close()without being drained -cannot be what happened. That message is published by a directory server, on a session with no
publisher thread, so it was written to the socket before
publish()returned. The conclusion ofthat candidate, that the change never reached the replication server, stands on other evidence and
this PR does not touch it; only the mechanism is ruled out. Hence
Refs, notFixes: #963 staysopen.
The change: a close sends the queue instead of dropping it
Where a publisher thread does exist,
close()setcloseInitiated, interrupted that thread andjoined it, and everything still in
sendQueuewent with it. TheStopMsgpublished afterwardsstill went out -
isRunningis false by then, so it takes the direct branch - so the peer read anorderly close with no sign that anything was missing.
PR #919 recorded this as a known limitation and named the fix: "Fixing it belongs in
Session-drain before interrupting, or report the forward from the publisher thread."
ReplicationServerShutdownSyncTestcarries a comment pointing at it as the reason one of itsassertions may fail. This takes the first of the two.
close()now sends that queue:which is what already lets the
StopMsgbe published on it;StopMsg, so that message stays last on the wire, which is what it means;StopMsgis skipped there:writing more to it cannot work. That is also the path where
close()runs on the publisherthread itself -
run()calls it when asend()threw - where the queue is unsendable byconstruction.
The budget.
close()is called from shutdown paths, so an unbounded drain would hold thethread shutting the server down for as long as a stalled consumer stays stalled, which is the
hazard #952 and #983 are about.
DRAIN_BUDGET_MSis 5 s, the same value asDSRSShutdownSync.REPLICA_OFFLINE_GRACE_PERIOD: a close has no reason to wait longer for one ofthese messages than the shutdown which is waiting on the close. A peer which is reading pays none
of it; a peer which is gone pays none either, its write failing at once. Only a peer which is alive
and not reading pays, and that is the case the old code answered by dropping the messages.
What is given up on is reported. A
logger.warnnames the session, how many messages it couldnot hand over and why. The silence is what cost the most to diagnose in #963.
LocalizableMessage.rawis the idiom this package already uses for a log line, so this needs nonew ordinal in
replication.properties.What the first CI run found, and why the case needed a reader
The first run of this branch failed on three ubuntu legs - and on nothing else: 32560 tests, one
failure, this suite's own. The peer had received 2025, 2134 and 1306 of the 3000 messages. Every
macos and windows leg passed, and so did ubuntu 21 and 26.
The give-up warning this change adds appeared in none of those logs, which is what made it
diagnosable: the drain had not given up on anything, so the whole queue had been written to the
socket. The loss was under the write, in the teardown.
close()ends withStaticUtils.close(plainSocket, secureSocket)straight after the last write,and the case had no reader on the closing side - so that side reached the close with inbound
bytes nobody had ever read. A close in that state ends the connection with a reset instead of a
FIN, and a reset discards whatever the peer has not read yet. Measured with a bare socket pair,
8 MiB written to a peer reading behind the writer, the only difference between runs being whether
the closing side drained its own inbound:
Connection resetConnection resetThat is the whole of it: the platform spread explains why the suite passed on every macos leg and
on this machine while losing half the queue on ubuntu.
The server does not have that condition where the drain does anything.
ServerHandler.shutdown()closes the session atServerHandler.java:946and joins itsServerReaderonly at:966, so the reader is still consuming inbound across the close. The pathsthat close without a live reader are the ones that skip the drain anyway:
Session.run()callsclose()after asend()threw, andServerReader'sfinally(:228) is reached either on anerror - both leave
sessionErrorset - or on aStopMsg, where the peer has announced it isleaving and there is nothing unread inbound. The directory-server side has no publisher thread at
all, so its queue is empty and the drain is a no-op there.
So the case now keeps a reader on the sending end, as the server does, and its
soTimeoutislifted because
receive()hands a read timeout tosetSessionError()- which would skip thedrain and test nothing. The read of the peer end reports what ended it, so a short read names its
cause instead of leaving the next reader to find this out again.
Limits, stated rather than left to be found
completion. Bounding a single write needs a non-blocking socket, which this session is not.
case would itself hang on that blocked write. The drain is covered; the give-up is not.
isRunningtrue and
closeInitiatedtrue, thewhile (!closeInitiated)loop ofpublish()does not run andthe call returns having done nothing. That is a different silent drop - at the door rather than in
the queue - and it is not touched here.
above shows, a close whose side has unread inbound resets the connection and the peer loses what
it has not read - which would undo a drain. Every server path where the drain does something has
a reader consuming inbound across the close, so this does not bite today, but it is a property of
the callers rather than of
close()itself. Making the close orderly regardless - reading theinbound to its end before closing the socket - is a change to every session teardown and belongs
in its own PR, not bundled here.
Testing
190 tests, 0 failures, 0 errors, 0 skips, on the branch as it stands on master. The set is wider than the change because the drain adds
time to a
close()whose peer is not reading, andclose()is on every shutdown and handshakepath of this package - the shutdown and handshake classes are in there to catch a timing shift
rather than a logic one.
Every assertion was built with its defect put back:
...SendsWhatIsStillQueuedWhenItIsClosedfails withthe close dropped 2820 of the 3000 messages published- 180 reached the peer through the socket buffers...HasReachedTheWireWhenPublishReturnsfails on the thread state, which is what stops that case from passing on both branches