Describe the bug
ReSyncTest.testResyncAfterRestore failed in CI: the entry added between the backup and the
restore never came back, and the 30 s the test waits went by with nothing happening at all — no
error, no replay attempt, no message on the wire.
ReSyncTest.testResyncAfterRestore:167->ReplicationTestCase.getEntry:759
Expected entry with dn "dc=fooUniqueName1,dc=example,dc=com" would exist expected [true] but found [false]
The whole sequence of the test fell inside one second (logs/errors of the failing run):
10:03:55 Backup task ... finished execution in the state Completed successfully
10:03:55 testResyncAfterRestore: backup done
10:03:55 testResyncAfterRestore: entry added
10:03:55 Restore task ... started execution
10:03:55 The backend userRoot is now taken offline
10:03:55 Restored backup file: 00000000.jdb (size 58002)
10:03:55 The database backend userRoot containing 3 entries has started
10:03:55 Directory server DS(123) has connected to replication server RS(104) for domain
"dc=example,dc=com" at 127.0.0.1:65531 with generation ID 8969
10:03:55 testResyncAfterRestore: restore done
<30 seconds of silence>
10:04:25 failed to wait for org.opends.server.util.TestTimer$SteppingTimer$1
The replica did announce a state behind the change. From logs/audit, the ds-sync-state of
dc=example,dc=com after the add and after the restore:
replace: ds-sync-state
ds-sync-state: 000001a0807967c2007b00000004 <- after the add of dc=fooUniqueName1
...
replace: ds-sync-state
ds-sync-state: 000001a080796545007b00000002 <- reloaded from the restored backend
So enable() → loadDataState() did what it is meant to do, and DS(123) reconnected asking for
everything after seqnum 2 while the change it needed is seqnum 4.
The replication server had already decided there was nothing to send. The thread dump taken at
the failure:
"Replication server RS(104) writing to Replica DS(123) for domain "dc=example,dc=com" at localhost/127.0.0.1:56934"
daemon TIMED_WAITING on org.opends.server.replication.server.MsgQueue@442cca58
at org.opends.server.replication.server.MessageHandler.getNextMessage(MessageHandler.java:390)
at org.opends.server.replication.server.ServerHandler.take(ServerHandler.java:991)
at org.opends.server.replication.server.ServerWriter.run(ServerWriter.java:100)
MessageHandler.java:390 is the msgQueue.wait(500) inside if (following). The handler for the
just-reconnected replica was already following — i.e. it had read the changelog once, found
nothing after the announced state, and concluded the replica was up to date.
Why it matters
following is a one-way door for a change that is already in the changelog:
// MessageHandler.java:302
boolean queueContributesToDomainState = fillLateQueue();
if (lateQueue.isEmpty())
{
// we could not find any messages in the changelog
// so the remote server is not late anymore.
synchronized (msgQueue)
{
if (isMsgQueueBelowThreshold())
{
following = true;
}
}
}
fillLateQueue() is the only reader of the changelog (getCursorFrom(serverState),
MessageHandler.java:429). Once following is set, the handler serves nothing but what add()
puts on its queue, and add() only ever carries changes published after the handler started
following. Nothing re-reads the changelog, so a change missed at that one moment is not sent again
— short of the queue overflowing, which is the single place that clears the flag
(MessageHandler.java:141).
This is not only a test problem. The same window is what a real replica depends on: a server that
restores a backup gets the changes it was restored past only if the changelog cursor, opened once
at the reconnect, yields them. If it comes back empty the replica silently stays short of those
changes, and the replication server considers it in sync.
What to look into
Why fillLateQueue() came back empty while the announced state was demonstrably behind. Two
candidates:
- The change was not yet visible to a cursor opened right then. The add, the backend going
offline, the restore and the reconnect all happened inside the same second, so the cursor was
opened on a changelog record written milliseconds earlier — worth checking what the file
changelog guarantees between ReplicationServerDomain.put() and a subsequent
getCursorFrom().
- The start position for the replica's own serverId in this path — the change to be resent
here originates from the very server the handler serves, which is the unusual part of
resync-after-restore compared to ordinary catch-up.
Whatever the cause turns out to be, treating "the changelog gave me nothing" as final is what makes
a transient miss permanent: a handler whose queue stays empty while the state it was given is
behind the domain state could read the changelog again instead of waiting forever.
Evidence
Describe the bug
ReSyncTest.testResyncAfterRestorefailed in CI: the entry added between the backup and therestore never came back, and the 30 s the test waits went by with nothing happening at all — no
error, no replay attempt, no message on the wire.
The whole sequence of the test fell inside one second (
logs/errorsof the failing run):The replica did announce a state behind the change. From
logs/audit, theds-sync-stateofdc=example,dc=comafter the add and after the restore:So
enable()→loadDataState()did what it is meant to do, and DS(123) reconnected asking foreverything after seqnum 2 while the change it needed is seqnum 4.
The replication server had already decided there was nothing to send. The thread dump taken at
the failure:
MessageHandler.java:390is themsgQueue.wait(500)insideif (following). The handler for thejust-reconnected replica was already following — i.e. it had read the changelog once, found
nothing after the announced state, and concluded the replica was up to date.
Why it matters
followingis a one-way door for a change that is already in the changelog:fillLateQueue()is the only reader of the changelog (getCursorFrom(serverState),MessageHandler.java:429). Oncefollowingis set, the handler serves nothing but whatadd()puts on its queue, and
add()only ever carries changes published after the handler startedfollowing. Nothing re-reads the changelog, so a change missed at that one moment is not sent again
— short of the queue overflowing, which is the single place that clears the flag
(
MessageHandler.java:141).This is not only a test problem. The same window is what a real replica depends on: a server that
restores a backup gets the changes it was restored past only if the changelog cursor, opened once
at the reconnect, yields them. If it comes back empty the replica silently stays short of those
changes, and the replication server considers it in sync.
What to look into
Why
fillLateQueue()came back empty while the announced state was demonstrably behind. Twocandidates:
offline, the restore and the reconnect all happened inside the same second, so the cursor was
opened on a changelog record written milliseconds earlier — worth checking what the file
changelog guarantees between
ReplicationServerDomain.put()and a subsequentgetCursorFrom().here originates from the very server the handler serves, which is the unusual part of
resync-after-restore compared to ordinary catch-up.
Whatever the cause turns out to be, treating "the changelog gave me nothing" as final is what makes
a transient miss permanent: a handler whose queue stays empty while the state it was given is
behind the domain state could read the changelog again instead of waiting forever.
Evidence
build-maven (ubuntu-latest, 17)https://github.com/OpenIdentityPlatform/OpenDJ/actions/runs/34206334572/job/101996575306
ReSyncTestin theubuntu-latest11, 21and 25 jobs of that same run, in ~2.5 s each — the failing job is not a slow resync, it is a
resync that never started
org.opends.server.backends.jdbc;ReSyncTestruns on the JEuserRootbackend)