Skip to content

[#925] Keep asking for a session restart until it has run - #981

Open
vharseko wants to merge 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:issues/925-session-restart-request
Open

vharseko wants to merge 2 commits into
OpenIdentityPlatform:masterfrom
vharseko:issues/925-session-restart-request

Conversation

@vharseko

@vharseko vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes #925.

Was stacked on #958, which has landed (776339a). The branch now sits on master directly - at cebef54, which is #926 (#974) - and the diff is the two commits alone: 0b35ffd ([#925] Keep asking for a session restart until it has run) and 2a2766f, which moves its message ordinal. The rounds #958 went through while this was open reached into the same code, so the rebases along the way were a merge of decisions rather than of hunks - the last section says which, and it still describes how this change meets what #958 put on master.

What was wrong

runRequestedSessionRestarts() takes the request before it runs the restart, and restartSession() stops the session in its first synchronized block and starts it again in the second. Anything thrown in between left the request cleared and the session stopped, and nothing asked for it again: no change is delivered over a session which is down, so no replay fails and no thread comes back to the recovery. The domain sat out of the topology - the change it could not replay still owned by the replication server, its ServerState stopped behind it - until the server was restarted or a configuration change happened to call restartService().

Worth being precise about the trigger, because the issue text names one which does not happen: broker.start() on an unreachable replication server does not throw. connectAsDataServer() swallows every connection failure - performPhaseOneHandshake() catches ConnectException, SocketTimeoutException and Exception, connectToReplicationServer() catches Exception - and leaves the domain in degraded mode, where the listener thread reconnects on its own. What makes the throw reachable is narrower and, in one respect, damning: enableService() is the one call on that road nothing guards, while the reconnect loop of the broker wraps the same connectAsDataServer() in a catch (Exception). Every failure that path is written to survive is fatal to this one, and what is fatal about it is the missing listener thread: it is the reconnection engine, so a domain which loses it has nothing left to bring the session back.

What changed

  • SessionRestartRequests (new) holds what the domain has been asked for - NONE < NOW < AFTER_BACKOFF, merged by taking the strongest. The request is still taken before the restart runs, which it must be: a change released while a restart is under way is not one that restart asks for, its delivery would be turned down as a duplicate of a change a replay thread still owns. Clearing the flag after the restart instead - the other half of the suggestion in the issue - would swallow exactly those requests.
  • A restart which could not run gives its request back, with the backoff whatever it was asked for with: a session which can not be started is what that wait exists for, and a NOW request given back bare would have the retry hammer once a second. [#922] Give a change back when the replay which owns it is unwound #958 has since put the request back on its own (round 2, blocking 4) - a finally which sets the flag again - and this replaces it: the give-back carries the backoff, and, the point of this PR, something runs what was given back. Under [#922] Give a change back when the replay which owns it is unwound #958 alone the restored request is run by "the next failed or abandoned replay of this domain", and on a session which is down there is none.
  • The wait belongs to the request rather than to the thread which runs it (the first asymmetry in the issue). A replay thread on its way out is owed no backoff - the backend is not what is going away - and a failing replay is owed one; either thread can end up running the other's request. Round 3 of [#889] Keep a change the replay could not apply out of the ServerState #892 traced this as unreachable ("a stopping thread's zero-delay restart cannot coexist with a requester owed a backoff") and it very nearly is: stopReplayThreads() sets the shutdown flag of each thread in a loop, so the window is the one between the first flag and the last. It costs nothing to close it here, since the request had to carry its own state anyway. The thread an OutOfMemoryError is ending, which [#922] Give a change back when the replay which owns it is unwound #958 exempts from the backoff for the same reason, asks for NOW as well.
  • The state checkpointer runs a request nobody else will, once a second. It is the one thread a domain has for as long as it is up, whatever its session is doing. It runs the restart outside its own monitor - shutdown() takes that monitor to wake it - and swallows and reports a failure rather than end on it: a domain whose checkpointer is gone saves no ServerState, and shutdown() waits for that thread.
  • abandonReplay() asks for the restart rather than running it (the second asymmetry). The threads of the pool are stopped one after the other and joined, so a live ds-cfg-num-update-replay-threads change cost one stop and reconnect per thread which was replaying a change - none of them waiting, each one a listener join plus a full handshake, and the configuration change waited for all of them. One restart is all the replication server needs to send back every change which was handed back.
  • The last resort of replay() keeps running its restart itself, as [#922] Give a change back when the replay which owns it is unwound #958 decided in its round 1 (non-blocking 4): on that road the thread is likely ending and the report is the line an operator acts on. What this branch adds there is the same as everywhere else - a restart which throws leaves its request standing, and the checkpointer runs it, where before the request went out with the thread.
  • The backoff is waited on a monitor rather than slept through, and disable() / shutdown() wake it. This is not in the issue; it is what the checkpointer needs in order not to hold a domain going down for up to ten seconds, and it removes the same stall for a replay thread stopReplayThreads() is joining.

Tests

SessionRestartRequestsTest (new) 6/6 - take/give-back, the merge, and that a request given back does not undo the one made meanwhile
UpdateOperationTest.aSessionRestartWhichCouldNotRunIsRunAgain (new) green; before the fix the entry is still there 60 s after the delivery which failed
UpdateOperationTest 32/32 - the 31 of master plus this one
RemotePendingChangesTest / AssuredReplicationPluginTest / DependencyTest 21/21, 14/14, 3/3

The new test was checked against both mutations it exists for: with the checkpointer's call removed it fails, and with the give-back removed it fails - each half of the fix is load-bearing on its own.

Two things to say plainly rather than bury.

failNextSessionRestarts(int) / getSessionRestartFailuresLeft() are @VisibleForTesting and there is a branch in restartSession() which production never takes. Nothing else reaches enableService(): connection failures are swallowed inside it, and the machinery #958 added throws from a replay, not from a restart. The precedent this PR first named, setReplayGiveUpDelay(), is gone - #901 replaced it with the configured replay-give-up-delay - so the one left in the class is setReplayDrainTimeout() from #945. If you would rather have no injection point in the class, the alternative I can see is dropping the integration test and keeping only the unit test of the holder, which leaves the give-back and the checkpointer unpinned.

The collapsed restarts of a num-update-replay-threads change have no test of their own here. Pinning "one restart rather than one per thread" needs the replay parking ShortCircuitPlugin gets in #941, and writing one here would have duplicated it.

#941 has since landed (d0422c6), and aChangeAStoppedReplayThreadHeldIsGivenBackAndDeliveredAgain sits in this class next to the change it was claimed to survive, and it is green. It is worth saying why it survives rather than only that it does. That test stops a replay thread while it holds a change and reads the assured ack the abandoned delivery published; under this change abandonReplay() no longer restarts the session itself, so the ack now goes out over a session which is still up, where before it raced the teardown. The test is not merely still valid - it is the less brittle for this change. Its comment on reading the ack rather than its ordering ("an ack published after the hand-back reaches it all the same") describes a race this change removes.

Rebased onto #958 as it went, and onto master once it landed

Two rounds of #958 landed on the same code between the opening of this PR and now, and git reported six hunks in LDAPReplicationDomain. Three were mechanical - #901 removed the replayGiveUpDelayInMs field and its setters this branch had sat next to, so they go; the outOfMemory parameter #958 gave recoverFromReplayFailure() becomes the request being NOW. Three were decisions:

The diff of the two commits against master is 506 insertions and 67 deletions over the same five files as before.

Rebased once more onto #958 round 4 (9618f12, on master at 13d57e0). Round 4 only grew the comment inside the finally this branch replaces, so the resolution above stands and those lines go with the block; the twelve of them are the whole difference from the previous version of the two commits. What put both PRs back into conflict was #928 (#973), which removed the NPE #958's test scaffolding relied on to fail a replay before the CSN is read - #958's own rebase moved that scaffolding to ModifyMsgWhoseOperationRefusesAControl, and this branch sits on it as it is.

#958 has since landed as 776339a, a squash of the 9618f12 this branch sat on, with the same tree. Rebased onto master there: the five commits it carried went, the two of this PR moved without a hunk to resolve, and the tree of the new head is the tree of the old one - git diff ca9ba89 823980c is empty.

Rebased once more onto master at 9ff409b, where #964 landed. It took WARN_CHANGELOG_READ_AGAIN_FOR_MISSING_CHANGES_321 at the end of replication.properties, next to the message this branch adds, and that was the whole conflict: one hunk, both sides kept, 321 above 325. Nothing in the Java met #964 - it changes the replication server's catch-up, this branch changes the domain's restart - and the two commits are what they were, 506 insertions and 67 deletions over the same five files. 325 is claimed by nothing on master, which holds 310-317, 319-321 and 326-327; the file has no ordinal twice.

Rebased once more onto master at cebef54, where #926 (#974) landed. It moved serviceStateLock and sessionGeneration up from LDAPReplicationDomain into ReplicationDomain, and this branch adds sessionRestartFailuresToInject right where they used to be - one hunk, the field block, resolved by taking master's side and keeping only the new field. Everything else merged without a word, and a range-diff against the previous version of the two commits shows context only: the moved declarations, and the sessionGeneration++ #974 took out of restartSession() next to failSessionRestartIfATestAskedFor(). #974 reached into the same restart, so the meeting point is worth a line: it counts the generation inside enableService(), and only once the session is up, so a start which threw leaves the generation where the stop put it. The injected failure of this branch sits between the generation guard and enableService(), which is the same case - the thread which stopped the session still owns it, and the retry the state checkpointer runs goes through a fresh disableService() as any other restart does. Nothing in this branch had to move for it. The two commits are still 506 insertions and 67 deletions over the same five files; 325 is still claimed by nothing on master, and the file has no ordinal twice.

@vharseko
vharseko requested a review from maximthomas September 9, 2026 06:19
@vharseko vharseko added bug replication concurrency Thread-safety / race-condition bugs java tests Test suites: fixing, enabling, un-disabling and removed java labels Sep 9, 2026
@vharseko
vharseko force-pushed the issues/925-session-restart-request branch from 58df624 to 8f9a7fd Compare September 9, 2026 09:39
@vharseko

vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Force-pushed: rebased on master at 2a7bb9d (was 92d88ca). Nothing in the change itself was touched - the diff against master is byte for byte the one that was here before, 1369 insertions and 75 deletions over the same nine files.

The conflict was one hunk, the import block of UpdateOperationTest: master had gained TimeoutException and AtomicReference from #941, this branch adds Supplier in the same place. Resolved by taking both.

What the rebase actually brought is worth a look, because it settles something this PR had only asserted. The last section of the description said the collapsed restarts of a num-update-replay-threads change have no test of their own here, that writing one would need the replay parking ShortCircuitPlugin gets in #941, and that #941's own test "stays valid under this change". #941 has now landed, so aChangeAStoppedReplayThreadHeldIsGivenBackAndDeliveredAgain sits in this class next to the change it was claimed to survive. It does: 22/22 green.

It survives for a reason worth stating rather than leaving as a passed test. That test stops a replay thread while it holds a change and then reads the assured ack the abandoned delivery published. Under this change abandonReplay() no longer restarts the session itself - it asks, and the state checkpointer runs it - so the ack now goes out over a session which is still up, where before it raced the teardown. Its own comment covers that race from the other side ("an ack published after the hand-back reaches it all the same"); this change removes it. The test is not merely still valid, it is the less brittle for this branch.

Run after the rebase, all green:

UpdateOperationTest 22/22
AssuredReplicationPluginTest 14/14
IsServerFailureTest (from master, #939) 23/23
RemotePendingChangesTest 18/18
SessionRestartRequestsTest 6/6
DependencyTest / PendingChangesTest (from master, #918) 3/3, 3/3

The two master classes are in the list because they are the ones which touch the files this branch changes: #939 opened isServerFailure() up for its test and #918 changed publishReplicaOfflineMsg(), both in LDAPReplicationDomain. Neither meets this change; they were run to say so rather than to assume it.

The description has been updated: the SHA to review is now 8f9a7fd, and the closing section says what became of the conflict it predicted.

One thing unchanged by the rebase: this is still stacked on #958, which is itself conflicting with master right now. The first two commits here are #958 rebased with that same import resolution, so it can be lifted straight over when you get to it.

@vharseko

vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Ordinal moved: ERR_REPLAY_SESSION_RESTART_FAILED 316 → 325 (31d7dfa).

Nothing catches this on the way in. The additions land in different parts of the file, so git merges
every pair of them without reporting a conflict - I merged all ten onto master to check, and the
result carried 310, 311, 315 and 316 twice each. The generator does not check either: it loads the
file into a Properties and keys on name and ordinal (MessagePropertyKey.compareTo), so both
sides compile. What comes out is two unrelated messages carrying one support ID, found by whoever
reads a log rather than by CI.

The open PRs which add to the file now hold 310-325 with nothing claimed twice:

310-313 #935 · 314 #959 · 315-317 #958, #985 · 318 #985, #988 · 319-320 #945 · 321 #964 ·
322 #968 · 323-324 #977 · 325 #981

No Java moved with it: the generated constant is the key name without its ordinal, so the rename is
confined to replication.properties. #935, #958 and #985 keep what they had.

This branch is not one of the six: 316 was free where it sits, on the earlier [#922] commits, from
before ERR_ACK_NOT_PUBLISHED existed. #958 gives that name 316, and #985 carries it, so the two
meet on master. ERR_ERROR_REPLAYING_CHANGE_315 is the [#922] message itself and stays where the
rest of that stack has it.

@vharseko

Copy link
Copy Markdown
Member Author

Force-pushed: rebased onto the current #958 at 31e633d (was 00fead7, the #958 of two review rounds ago), which sits on master at 5d176c6. The commits to review are 5323358 and 0e91a8f.

This time the conflict was not one of hunks. #958 went through two rounds since this was opened, and both reached into the code this PR changes, so what git reported in LDAPReplicationDomain - six hunks - had three decisions inside it, resolved as follows.

The mechanical three: #901 removed replayGiveUpDelayInMs and its two setters, which this branch had sat next to - gone, and the new test never used them; and the ordinal, which this time git did report, since ERR_ACK_NOT_PUBLISHED_316 and the 316 this branch first took landed in one hunk. 0e91a8f resolves it to 325, as before; no ordinal is in the file twice.

One consequence for the description's "say plainly" paragraph: the precedent it named for the test hook, setReplayGiveUpDelay(), no longer exists - #901 replaced it with the configured property - so the one left in the class is setReplayDrainTimeout() from #945. Rewritten to say so.

Run after the rebase, all green:

UpdateOperationTest 30/30 - the 29 of #958 plus aSessionRestartWhichCouldNotRunIsRunAgain
SessionRestartRequestsTest 6/6
RemotePendingChangesTest 21/21
AssuredReplicationPluginTest 14/14
DependencyTest 3/3

The description is updated: the SHAs, the bullets the two #958 rounds touched, and a closing section which says what was merged and how.

@vharseko

Copy link
Copy Markdown
Member Author

Force-pushed: rebased onto the current #958 at 9618f12 (was 31e633d), which sits on master at 13d57e0. The commits to review are 31f46f7 and ca9ba89.

The two commits of this PR are what they were: 506 insertions and 67 deletions over the same five files. The one difference from the previous version is twelve lines of a comment #958 round 4 grew inside the finally this branch replaces with giveBack(AFTER_BACKOFF) - they go with the rest of that block, for the reasons given in the previous rebase note. The other hunk git reported was replication.properties, where #959's 326 and 327 landed next to the message this branch adds: both sides are kept, and 325 is still claimed by nothing else among the open PRs.

What put both PRs back into conflict is worth a line, because it was not textual. #928 (#973) removed the NPE in getEntryDN().equals(SET_PERMISSIVE_MODIFY_FOR_DN) that #958's test scaffolding was built on: ModifyMsgWithAnUnparseableOperationDN no longer fails the replay before OperationContext.getCSN(op) is read - the operation runs, reports the syntax of its DN, and the change is stepped over. Git reported one hunk of that and merged the rest without a word; taken at face value, four of #958's cases would have compiled and asserted a give-back of a change the replay had just recorded as applied. #958's rebase moves them to ModifyMsgWhoseOperationRefusesAControl, the message #928 gave the #889 test in its place - addRequestControl(ManageDsaIT) throws on the empty control list, the same road one line earlier - and this branch sits on that rebase as it is. Nothing in LDAPReplicationDomain moved for it.

Run after the rebase, all green:

UpdateOperationTest 32/32 - the 30 of before, plus aModifyWhoseEntryDNDoesNotParseIsReportedRatherThanThrownOn from #928 and aChangeHandedOutAsADependencyIsGivenBackWhenItsReplayIsUnwound from #958 round 4
SessionRestartRequestsTest 6/6
RemotePendingChangesTest 21/21
AssuredReplicationPluginTest 14/14
DependencyTest 3/3

The description is updated: the SHAs and the base.

@vharseko
vharseko force-pushed the issues/925-session-restart-request branch from ca9ba89 to 823980c Compare September 12, 2026 13:19
@vharseko

Copy link
Copy Markdown
Member Author

Force-pushed: rebased onto master at 776339a (was on #958 at 9618f12). #958 has landed, so this is no longer stacked on anything: the commits to review are 6d6b379 and 823980c, and the diff against master is 506 insertions and 67 deletions over the same five files.

The conflict GitHub reported was the squash, not the code. 776339a is #958's 9618f12 squashed onto master, with the same tree, while this branch still carried the five commits of it. Moving the two commits over them left no hunk to resolve, and the tree of the new head is the tree of the old one - git diff ca9ba89 823980c is empty.

Ordinal 325 is still claimed by nothing on master, which holds 310-317, 319-320 and 326-327; the file has no ordinal twice.

Run on the new head: SessionRestartRequestsTest 6/6 - and that is the only class re-run locally. The tree being the one of the previous note, its runs (UpdateOperationTest 32/32 among them) were runs of this code; CI runs the rest on this head.

The description is updated: the stacking paragraph is gone, the SHAs and the base, and the closing section says what the squash did to the branch.

@vharseko

Copy link
Copy Markdown
Member Author

Force-pushed: rebased onto master at 9ff409b (was 776339a). The commits to review are 1dc39e1 and 3592ab5, and the diff against master is 506 insertions and 67 deletions over the same five files.

The conflict was one hunk, the tail of replication.properties: #964, which is 9ff409b, took WARN_CHANGELOG_READ_AGAIN_FOR_MISSING_CHANGES_321 where this branch adds its message. Resolved by keeping both, 321 above 325 - in both commits, since the first adds the message as 316 and the second renames it. master now holds 310-317, 319-321 and 326-327; 325 is still claimed by nothing, and the file has no ordinal twice.

Nothing in the Java met #964: it changes MessageHandler and DataServerHandler on the replication server, this branch changes the domain's restart, and git merged the rest without a word. A range-diff of the two commits against their previous version shows the properties context and nothing else.

About the red leg on the previous head (JDK 26): it was aChangeHandedOutAsADependencyIsGivenBackWhenItsReplayIsUnwound from #958 asserting which replay thread picks a parked change up - #1036, which #1037 addresses - and the seventeen failures behind it are its cascade, the class not replaying anything once that test has failed. The other four ubuntu legs of the same run passed the class. Not this change.

Run on the new head: SessionRestartRequestsTest 6/6, and a test-compile of the module - ReplicationMessages generates with both 321 and 325 in it. The tree of the two commits being what it was, the runs of the previous notes (UpdateOperationTest 32/32 among them) were runs of this code; CI runs the rest on this head.

The description is updated: the SHAs, the base, and the closing section says what #964 put in the file.

@vharseko
vharseko requested review from maximthomas and removed request for maximthomas September 14, 2026 07:42
… has run

The recovery from a failed replay takes the request before it runs the restart, and
restartSession() stops the session in its first synchronized block and starts it again
in the second. Anything thrown in between left the request cleared and the session
stopped, and nothing asked for it again: no change is delivered over a session which is
down, so no replay fails and no thread comes back to the recovery. The domain sat out of
the topology - the change it could not replay still owned by the replication server, its
ServerState stopped behind it - until the server was restarted or a configuration change
happened to restart the service. The throw is narrow but not theoretical: enableService()
is the one call on that road nothing guards, while the reconnect loop of the broker wraps
the same connectAsDataServer() in a catch, so every failure that path is written to
survive is fatal to this one.

The request is a SessionRestartRequests now. It is still taken before the restart runs, as
it must be - a change released while a restart is under way is not one that restart asks
for - and it is given back when the restart could not run, with the backoff whatever it
was asked for with, since a session which can not be started is what that wait exists for.

The wait belongs to the request rather than to the thread which runs it: a request made by
a replay thread on its way out is owed none, one made by a failing replay is owed one, and
either thread can end up running the other's request.

Nothing else runs a request which is left standing, so the state checkpointer of the
domain does, once a second: it is the one thread a domain has for as long as it is up,
whatever its session is doing. That also lets abandonReplay() ask for the restart rather
than run it - the threads of the pool are stopped one after the other and joined, so a
live num-update-replay-threads change cost one stop and reconnect per thread which was
replaying a change, none of them waiting, while the configuration change waited for all of
them - and it runs the bare hand-back of issue OpenIdentityPlatform#922, which asked for a restart nothing was
going to run.

The backoff is waited on a monitor rather than slept through, and a domain which is going
away or is being disabled wakes it: the thread which holds it is a replay thread the
shutdown of the pool joins, or the checkpointer the shutdown of the domain waits for.
@vharseko
vharseko force-pushed the issues/925-session-restart-request branch from 3592ab5 to 2a2766f Compare September 14, 2026 09:54
@vharseko

Copy link
Copy Markdown
Member Author

Force-pushed: rebased onto master at cebef54 (was 9ff409b). The commits to review are 0b35ffd and 2a2766f, and the diff against master is 506 insertions and 67 deletions over the same five files.

The conflict was one hunk, the field block of LDAPReplicationDomain: #926 (#974), which is cebef54, moved serviceStateLock and sessionGeneration up into ReplicationDomain, and this branch adds sessionRestartFailuresToInject right where they used to be. Resolved by taking master's side and keeping only the new field - the two moved declarations are not brought back. Everything else merged without a word, and a range-diff against the previous version shows context only: the moved declarations, and the sessionGeneration++ #974 took out of restartSession() next to failSessionRestartIfATestAskedFor().

Worth a line, since #974 reached into the same restart: it counts the generation inside enableService(), and only once the session is up - a start which threw leaves the generation where the stop put it. The injected failure of this branch sits between the generation guard and enableService(), so a restart which fails is that same case: the thread which stopped the session still owns it, and the retry the state checkpointer runs goes through a fresh disableService() as any other restart does. Nothing in this branch had to move for it.

Ordinal 325 is still claimed by nothing on master, which holds 310-317, 319-321 and 326-327; the file has no ordinal twice.

Run on the new head, all green:

UpdateOperationTest 32/32 - aSessionRestartWhichCouldNotRunIsRunAgain among them
LDAPReplicationDomainConfigChangeTest (touched by #974) 9/9
SessionRestartTest (added by #974) 2/2
SessionRestartRequestsTest 6/6

The description is updated: the SHAs, the base, and the closing section says what #974 did to the file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug concurrency Thread-safety / race-condition bugs replication tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replication: a session restart request is consumed before the restart runs

1 participant