[#889] Keep a change the replay could not apply out of the ServerState - #892
[#889] Keep a change the replay could not apply out of the ServerState#892vharseko wants to merge 6 commits into
Conversation
…ut of the ServerState A change whose replay failed with anything other than NO_OPERATION, BUSY or UNAVAILABLE was recorded as replayed: the ServerState advanced past it, the replication server never sent it again, and an assured SAFE_READ ack went back to the originating master as if the change had been applied. The replica silently diverged while reporting itself fully caught up. The four solveNamingConflict() overloads collapsed two different outcomes into one "return true": the operation became a no-op after conflict resolution, and the operation simply failed. They now report a ConflictResolution, so replay() can tell them apart, and the four copies of the error log move to the single place which decides what to do. A failure of the server itself - the backend being offline or rebuilt, or the storage failing to serve the operation, which BackendImpl reports with the server-error-result-code - is now retried like an unavailable backend and, if it keeps failing, left out of the ServerState: the replication server still owns the change, so the session is restarted and the change is delivered and replayed again. Every failed replay reports the error in the ack, so an assured write is no longer told it is durable here, and counts in the new replayed-updates-failed monitor attribute. A change which can never be applied on this replica would otherwise stop it for good, so after MAX_REPLAY_ATTEMPTS deliveries it is skipped - but loudly, with the new UnreplayedChange alert telling the administrator that this replica has diverged and must be reinitialized. Failures which are not the server's fault keep being skipped as before, with the same ack, counter and alert. Tests: RemotePendingChangesTest covers the bookkeeping the fix relies on, UpdateOperationTest.failedReplayIsNotRecordedAsReplayed covers the redelivery and the give-up, and a new test in AssuredReplicationPluginTest covers the error ack, which the upstream TODO left untested.
maximthomas
left a comment
There was a problem hiding this comment.
The diagnosis is right and the direction is right — a replay failure must not advance the ServerState. But in the
exact scenario this targets, a backend outage failing several in-flight changes, the new recovery path restarts the
session without bound and re-applies changes that were already applied. Two blockers, three majors below. Nothing
was executed; all of it is traced from source.
The MAX_REPLAY_ATTEMPTS bound is unreachable (blocker)
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2639
The attempt count lives in two scalar fields, not a per-CSN map:
lastFailedCSNAttempts = csn.equals(lastFailedCSN) ? lastFailedCSNAttempts + 1 : 1;
lastFailedCSN = csn;A failure for a different CSN resets the count to 1, so attempts > MAX_REPLAY_ATTEMPTS (:2644) is only reached
when one CSN fails four times with nothing else failing in between. A backend outage fails every change in flight.
With two failing changes:
recover(c1) lastFailedCSN=null -> attempts=1 -> disableService/clear/enableService
recover(c2) c2 != c1 -> attempts=1 -> restart
RS resumes from a ServerState covering neither, resends both
recover(c1) c1 != c2 -> attempts=1 -> restart ...
The counter never leaves 1 and there is no sleep or backoff anywhere in :2623-2671. The escape hatch the PR
describes — "a change which can never be applied here does not stop the replica for good" — is dead code, and the
replica restarts its replication session indefinitely.
This holds single-threaded too; two CSNs alternating through one thread reset each other identically.
synchronized (replayFailureLock) at :2644 does not help: it makes the read-modify-write atomic, but one slot is
still one slot.
UpdateOperationTest.failedReplayIsNotRecordedAsReplayed misses it because it publishes exactly one DeleteMsg, so
the counter walks 1,2,3,4 and the skip fires as intended. A test with two concurrent failing CSNs would catch it.
Suggested: key the count on the CSN (a bounded map or small LRU), dropping entries on successful replay and on skip.
RemotePendingChanges.clear() re-arms OPENDJ-1115 (blocker)
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:212
clear() empties pendingChanges unconditionally, but commit() flushes only the contiguous committed prefix —
it breaks at the first uncommitted entry. This fix deliberately leaves the failing change uncommitted, so every
later change that another replay thread applies successfully stays in the map as committed-but-unflushed, with its
CSN absent from the ServerState. clear() drops those too.
c1 fails, stays uncommitted
c2, c3 applied OK but sit behind c1 -> ServerState covers none of the three
clear() -> all three forgotten
enableService() -> RS resends c2, c3 -> putRemoteUpdate() now returns true (map is empty)
-> c2, c3 replayed and acked a second time
That putRemoteUpdate() check at LDAPReplicationDomain.java:4425 is the OPENDJ-1115 guard, and its own comment
says surviving pendingChanges is exactly what makes session failover safe. Correctness now rests entirely on
conflict resolution being idempotent for a replayed Add/Modify/Delete — the reliance OPENDJ-1115 was filed to remove.
The pre-existing session restarts (disable() at :3304, the fractional reconnect at :702/:729) do not call
clear().
Only the uncommitted changes need forgetting. Keeping the committed ones is still correct: once the failing CSN
commits, the prefix flushes and the state advances over them. A selective removal gets the stated benefit without
reopening OPENDJ-1115.
clear() runs against a replay queue nobody drained (major)
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:234
recoverFromReplayFailure does disableService(); clear(); enableService();. disableService() stops the broker
and joins the listener thread only — it never stops the replay threads, and updateToReplayQueue is a static
10 000-entry queue shared by every domain. Messages for this domain are still in it, pollable, while the map is
empty. Then:
// markInProgress, no null guard; activeAndDependentChanges is a ConcurrentSkipListSet
activeAndDependentChanges.add(pendingChanges.get(msg.getCSN()));Two outcomes, depending on whether the redelivered copy has re-run putRemoteUpdate() yet:
- not yet —
get()returnsnull,add(null)throws NPE intoReplayThread's catch-all.replay()never runs:
no replay, noprocessUpdateDone(), no assured ack, so the originating master waits out its SAFE_READ
timeout instead of getting the honest error ack this PR adds. OneERR_EXCEPTION_REPLAYINGper queued change. - already — the stale copy and the redelivered copy are both replayed: same CSN, two threads, one map entry. The
loser hitscommit()->NoSuchElementException->ERR_OPERATION_NOT_FOUND_IN_PENDINGand returns early, so it
is applied but never recorded.
switchQueueLock does not serialise this — it is released before domain.replay(), and clear() is reached from
inside replay(). A null guard alone would turn the first case into a silent drop; the queue needs draining for
this domain before clear().
The shutdown guard reads the wrong flag (major)
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2630
private boolean recoverFromReplayFailure(CSN csn, AtomicBoolean shutdown) {
if (shutdown.get() || disabled) return true;That shutdown is the parameter threaded down from replay(LDAPUpdateMsg, AtomicBoolean) — the ReplayThread's
flag — and it shadows the domain field at :352. disabled is set only inside disable(), and shutdown() (:2242)
calls disableService() directly rather than disable(), so it stays false. Neither guard sees domain shutdown.
MultimasterReplication.finalizeSynchronizationProvider() runs domain.shutdown() for every domain at :572 and
stopReplayThreads() only at :576, so the thread flag is false throughout — and that is precisely when replays fail,
because the backend is going offline and returns a code isServerFailure() now routes here. Both guards pass,
enableService() runs, ReplicationBroker.start() clears its own shutdown flag and reconnects: a broker and a fresh
listener thread come back up on a domain whose alert generator, flush thread and RSUpdater are already gone.
Before this PR no replay failure ever called enableService(). Checking the domain's own state (and renaming the
parameter — the shadowing is what hid this) is enough.
isServerFailure() keys off ResultCode.OTHER (major)
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2583
return result == ResultCode.UNAVAILABLE
|| result == getServerContext().getCoreConfigManager().getServerErrorResultCode();server-error-result-code defaults to ResultCode.OTHER — not a storage-specific code, but what the server returns
whenever nothing more specific applies. ReferentialIntegrityPlugin.java:1155 does
catch (Exception de) { stopProcessing(ResultCode.OTHER, ...) }, reached from doPreOperation(add):1054 and
doPreOperation(modify):985, neither guarded by isSynchronizationOperation(); SaltedSHA1PasswordStorageScheme
and SaltedSHA512PasswordStorageScheme do the same at :447/:451.
So a deterministic referential-integrity failure now costs, per delivery, ten rounds of Thread.sleep(50) at :2375
plus a full session restart, three times over — where before it fell into solveNamingConflict, was logged, and was
stepped over in one pass. It is also a realistic input for the first blocker: one misconfiguration fails many
changes at once.
Separately, the branch sits ahead of conflict resolution (:2375 vs :2386) and the knob is unvalidated —
GlobalConfiguration.xml:181 constrains it only with <adm:integer lower-limit="0"/> and CoreConfigManager does a
bare ResultCode.valueOf(). Setting it to 32, 66, 67 or 68 makes those codes bypass solveNamingConflict entirely.
Detecting the storage failure at its source — a marker exception type, or the exception cause — would be sturdier
than matching a configurable result code. Failing that, move the branch after conflict resolution.
Nits
replayed-updates-failedcounts attempts, not changes: the threenumFailedReplayedUpdates.incrementAndGet()
sites (:2441, :2467, :2512) are all insidereplay(), re-entered on every redelivery, whilereplayed-updates-ok
is incremented once per committed change (:2018). One unreplayable change contributes up to 4 — and unboundedly
once the first blocker fires.- Off-by-one in the skip:
attempts > MAX_REPLAY_ATTEMPTSwithattemptsstarting at 1 skips on the fourth
delivery, andERR_REPLAY_SKIPPING_CHANGE_308("after %d attempts") prints 4 while the constant and the PR
description both say 3. - The attempt counter is never reset:
lastFailedCSN/lastFailedCSNAttemptsare assigned only at :2639-2640
— not on a successful replay, not after a skip. A CSN skipped once is skipped again with zero retries if
redelivered; a change that fails, succeeds, then fails much later starts at attempt 2. - The new alert type is undocumented:
org.opends.server.replication.UnresolvedConflictis listed in
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc:2179and the docbkx copy at
chap-monitoring.xml:980;UnreplayedChangeappears in neither.enabled-alert-typeis an allow-list — "if
there are any values for this attribute, then only alerts with one of the specified types are allowed" — so an
operator with a non-empty list gets no notification of a diverged replica and no documented string to add. - A self-healing retry is logged at ERROR:
ERR_REPLAY_RETRYING_CHANGE_307fires vialogger.erroron every
attempt of a path the code expects to recover from; the neighbouring retry messages are
WARN_RETRYING_BIND_CHANGELOG_301/NOTE_BOUND_CHANGELOG_AFTER_RETRY_302. Worth aWARN_prefix — and worth
mentioning in the description, which announces onlyERR_REPLAY_SKIPPING_CHANGE. - The advertised retry-in-place success is untested: both new tests keep the short circuit registered for the
whole run, so all ten in-loop attempts fail and only the give-up path is observed — nothing shows that a transient
failure clearing within the window is replayed exactly once and committed. Both injectResultCode.OTHER, so the
UNAVAILABLEhalf ofisServerFailure()is unexercised, and the alert assertion is
assertThat(DummyAlertHandler.getAlertCount()).isGreaterThan(initialAlerts)— any alert emitted during the three
session restarts satisfies it, including one of a different type.
…nd forget only what was not replayed The count of failed replays lived in two scalar fields, so a backend which fails every change in flight had each of them reset the count of the previous one: the give up after MAX_REPLAY_ATTEMPTS was never reached and the replica restarted its session to the replication server without end. The count is kept per CSN now and dropped as soon as the change is replayed or given up on, and the session is left down for a moment before the change is asked for again. Restarting the session forgot every pending change, including the ones replayed while an older change was failing: those are not in the ServerState yet, so the replication server sends them again and an empty pending list had them replayed and acked a second time - the duplicate check of OPENDJ-1115. Only the changes which were not replayed are forgotten now, and putRemoteUpdate() no longer overwrites the copy which is listed with the one the new delivery came with, which lost the fact that it had been replayed. A message which is not the delivery listed as pending is dropped rather than replayed: it was waiting in the replay queue, shared by every domain, while the session was restarted. markInProgress() reports it instead of adding a null to activeAndDependentChanges, which threw an NPE into the replay thread and left the assured ack unsent. A duplicate delivery no longer has the listener push its CSN to the ServerState either: the copy which is listed owns the change and records it once it really has been replayed, while the ack and the window credit stay per delivery. The guard of the recovery read the AtomicBoolean of the replay thread, which shadowed the field of the domain, and the "disabled" flag which shutdown() does not set: a replay failing while the domain was being shut down brought a broker and a listener thread back up on it. It reads the state of the domain now, and reads it again after the wait. isServerFailure() no longer takes a change away from conflict resolution: server-error-result-code is configurable and is not validated as a result code, so the codes solveNamingConflict() solves are excluded from it. replayed-updates-failed counts changes really skipped, not attempts; the change is skipped on the MAX_REPLAY_ATTEMPTS-th attempt and the message says so; the retry is logged as WARN_REPLAY_RETRYING_CHANGE; the UnreplayedChange alert is documented in the admin guide and is not raised again for a minute, since one cause makes every change in flight unreplayable. Tests: two changes failing at once are both given up on, a failure which clears within the retry window has its change replayed exactly once, committed changes survive a session restart and the previous delivery of a change is not replayed.
|
Thanks - the review was traced from the source and it holds. Both blockers and all three majors are fixed in 8d3a0cc, together with every nit. Two of the fixes turned out to need more than what was suggested, and one of the majors is answered rather than applied; details below. The
|
maximthomas
left a comment
There was a problem hiding this comment.
Round 1's five findings are genuinely addressed: the per-CSN map is real, the shutdown guard now
re-reads a volatile field after the wait, and everyChangeWhichCanNotBeReplayedIsGivenUpOn does
fail against e62617b. The CONFLICT_RESULT_CODES exclusion closes the misconfiguration case as
described. Two new blockers below, four majors, eight minors. Nothing executed; all traced from source.
issue (blocking): clearUncommitted() drops changes another replay thread is executing right now
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:225
final PendingChange change = it.next();
if (!change.isCommitted())
{
dependentChanges.remove(change);
activeAndDependentChanges.remove(change);
it.remove();
}A change is uncommitted for the whole duration of its backend operation. disableService() stops the
broker and joins listenerThread only — the replay threads are MultimasterReplication's shared pool
and are never signalled or joined. markInProgress() guards the queue take, which an in-flight replay
has already passed. The default pool is >= 16 threads and an outage fails everything at once, so this
is the normal case, not a race.
That operation then succeeds:
synchronize():2058→remotePendingChanges.commit(curCSN)→NoSuchElementException:2064logsERR_OPERATION_NOT_FOUND_IN_PENDINGand returns- nothing else advances the state — the listener's
state.updateis dead now thatprocessUpdate()
always returnsfalse - the RS resends,
putRemoteUpdate()finds no entry, the change is replayed a second time
The double replay itself is absorbed (solveNamingConflict for Add/Delete/ModDN,
AttrHistoricalMultiple.processAddConflict for Modify). What is not absorbed: the change is also
dropped from activeAndDependentChanges, so its dependency ordering is lost. A child Add resent while
the parent Add is still in op.run() sees no dependency, gets NO_SUCH_OBJECT, and
solveNamingConflict:3200-3216 renames it to a conflict RDN. That divergence is permanent.
This is round 1's blocker moved rather than removed — committed entries are protected, in-progress ones
are not.
Suggested: skip changes present in activeAndDependentChanges (markInProgress() already puts them
there) and let their own replay commit normally; a resend then hits putIfAbsent → duplicate →
processUpdate false, which is the intended path.
issue (blocking): an orphan in dependentChanges makes getNextUpdate() throw for the life of the domain
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:313
checkDependencies() reads under the read lock, releases it, then registers under a different lock:
private PendingChange getPendingChange(CSN csn) {
pendingChangesReadLock.lock();
try { return pendingChanges.get(csn); }
finally { pendingChangesReadLock.unlock(); } // <-- released here
}
private void addDependency(PendingChange dependentChange) {
dependentChangesLock.lock(); // <-- pendingChanges not held
try { dependentChanges.add(dependentChange); }
finally { dependentChangesLock.unlock(); }
}clearUncommitted() holds both locks but cannot see a change not yet inserted. It removes the change
from pendingChanges; addDependency() then puts it into dependentChanges → an entry absent from
pendingChanges. Then, at :293:
if (!dependentChanges.isEmpty())
{
PendingChange firstDependentChange = dependentChanges.first();
if (pendingChanges.firstKey().isNewerThanOrEqualTo(firstDependentChange.getCSN()))pendingChanges is typically empty right after clearUncommitted() (only committed-unflushed entries
survive), so firstKey() throws NoSuchElementException. It is thrown at
LDAPReplicationDomain.java:2576, outside the try, so it escapes replay() into ReplayThread's
catch (Exception) → ERR_EXCEPTION_REPLAYING_REPLICATION_MESSAGE. Nothing removes the orphan, so it
recurs on every later replay() that ends with an empty map. If the map is non-empty and
firstKey >= orphan, getNextUpdate() instead returns the stale message and replay() runs it
without markInProgress() — bypassing the new identity check.
Not pre-existing: the invariant dependentChanges ⊆ pendingChanges held before this PR (commit()
removes only committed entries, and a change is uncommitted while its own thread is in
checkDependencies). That invariant is exactly what made the unguarded firstKey() safe.
clearUncommitted() is the first remover that can race an uncommitted entry.
Suggested: hold pendingChangesReadLock across getPendingChange + addDependency, or re-check
membership inside addDependency. Guard firstKey() with isEmpty() as well — but not instead, or
a permanent break becomes a silent stale replay.
issue (non-blocking): the total retry budget is ~4.5 s, for an outage the javadoc measures in minutes
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:321
Per change: 10 in-place iterations × sleep(50) ≈ 500 ms, then attempt 1 → wait 1 s → restart →
≈ 500 ms, attempt 2 → wait 2 s → restart → ≈ 500 ms, attempt 3 → skip. ≈ 4.5 s, plus the 64–329 ms per
enableService() you measured. skipUnreplayableChange() then advances the ServerState over a change
that was never applied.
isServerFailure()'s own javadoc names the case it exists for:
the backend being offline or rebuilt (OPENDJ-49), or the storage failing to serve the operation
An index rebuild, an import, a restore or a storage failover lasts minutes. So for the exact scenario
this PR targets, every change in the outage window is permanently skipped and the replica diverges —
with the once-a-minute-per-domain throttle collapsing a mass divergence into one alert.
Not a regression against master (which committed after ~500 ms, silently). But "a change which can never
be applied here does not stop the replica for good" currently means never = 4.5 seconds.
Suggested: a time-based budget (give up after N minutes of continuous failure), or make
MAX_REPLAY_ATTEMPTS / REPLAY_RETRY_DELAY_IN_MS configurable, or exempt UNAVAILABLE from give-up
and bound only per-change failures. Whichever — restate it in the description.
issue (non-blocking): neither headline test exercises the new machinery
opendj-server-legacy/src/test/java/org/opends/server/replication/UpdateOperationTest.java:1441
assertTrue(ShortCircuitPlugin.getShortCircuitCount(OperationType.DELETE, "PreParse") >= 2,
"the change was not sent again after its replay failed");That is the test's proof of redelivery, and the in-place retry loop already satisfies it. :2426 does
Thread.sleep(50); continue; without reassigning nextOp, so the same operation object is re-run;
DeleteOperationBasis.run():189 resets the result to UNDEFINED and :203 invokes
invokePreParseDeletePlugins unconditionally. Delivery #1 alone drives the count to 10 in ~500 ms —
before recoverFromReplayFailure() has run once. Delete clearUncommitted(), putIfAbsent() and the
whole redelivery path and this assertion still passes.
.../UpdateOperationTest.java:1606 — transientReplayFailureIsRetriedAndTheChangeApplied registers
maxTimes=3 against retryCount=10. Attempts 1–3 short-circuit and sleep/continue; attempt 4 passes
through and succeeds. Four of ten slots used, so control never leaves the while loop:
countReplayFailure(), failedReplayAttempts, MAX_REPLAY_ATTEMPTS, waitBeforeSessionRestart(),
clearUncommitted() and enableService() are all unreached. That UNAVAILABLE → sleep/continue branch
already exists on master (:2306, :2357-2364), so the test passes against unmodified code.
It also does not assert exactly-once — only assertNull(getEntry(...)). A second application returns
NO_SUCH_OBJECT → solveNamingConflict → NOTHING_TO_DO → updateError, incrementing nothing and
invisible to every assertion.
Net: fail → session restart → applied has no test, and neither blocker above sits in code these tests
touch. 82/82 green is worth less than it reads.
Suggested: set maxTimes above the 10 in-place slots (12) so success requires a real restart; count
deliveries (distinct operation identity, or a restart counter), not plugin invocations.
issue (non-blocking): committed-but-unflushed changes are stranded if the domain is disabled mid-recovery
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2781
waitBeforeSessionRestart(attempts);
if (shutdown.get() || disabled)
{
// The domain went away while this thread was waiting.
return true;
}
enableService();commit() is now the only thing that advances the ServerState for a replayed change — state.update
is nowhere else in RemotePendingChanges, and the listener's state.update cannot fire because
processUpdate() returns false for every LDAPUpdateMsg. clearUncommitted() removes the blocking
head and deliberately keeps the committed entries, but does not flush.
If disabled was set by processImportBegin / processRestoreBegin → disable(), enableService()
is never reached and nothing clears remotePendingChanges on disable/enable. Those committed entries
survive the cycle, so every later resend hits putIfAbsent → false → processUpdate false → no
state.update. The ServerState never covers them for the life of the domain object,
remote-pending-changes-size stays non-zero, and the RS re-ships them on every reconnect.
Suggested: clear remotePendingChanges in disable() — it already does state.save(); state.clearInMemory(), so the pending map is the one piece of that state left behind.
Not the fix: flushing the prefix after clearUncommitted(). The ServerState is a per-serverId
watermark, so flushing c2/c3 after dropping the failing c1 advances past c1 and reintroduces #889.
question (non-blocking): should a retried change still send an error ack on every attempt?
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2559
} finally
{
if (!dependency)
{
processUpdateDone(msg, replayErrorMsg);
}
}
if (replayFailed && recoverFromReplayFailure(csn, replayThreadShutdown))
{
// The ack has been published and the change, still owned by the replication
// server, is being delivered again: there is nothing left to replay here.The comment says this is deliberate, so this is a question rather than a defect claim. In SAFE_READ the
originating client's result derives from those acks. Before this PR a failed replay was never retried,
so an error ack was final and truthful. Now a change that fails once and succeeds on attempt 2 reports
a replay failure at this replica to the client that made it — a new false negative — and one CSN emits
up to three AckMsg across three sessions.
Was the false negative part of the trade? If not: publish processUpdateDone once the change is
resolved (replayed / skipped / given up), or at least don't set replayErrorMsg on an attempt that will
be retried. Either touches AssuredReplicationPluginTest.testSafeReadModeReplyWithReplayError, whose
5000 ms timeout now covers a path containing a full disable / 1 s sleep / enable cycle.
nitpick (non-blocking): the 1000-entry bound evicts the entry it exists to protect
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2703
while (failedReplayAttempts.size() > MAX_FAILED_REPLAY_ATTEMPTS_TRACKED)
{
// Every change in flight is failing: keep the counts of the most recent ones,
// the oldest ones have been given up on long ago.
failedReplayAttempts.pollFirstEntry();
}pollFirstEntry() on a ConcurrentSkipListMap<CSN, Integer> removes the lowest CSN — the oldest,
i.e. the head-of-line change the RS resends first and the one closest to MAX_REPLAY_ATTEMPTS. The
comment's premise ("given up on long ago") is what the eviction prevents. Past 1000 tracked CSNs the
blocking change never reaches 3 and the domain restarts indefinitely — round 1's blocker re-armed.
I could not close whether >1000 CSNs are ever tracked at once (broker window vs. the shared 10 000-entry
replay queue), so this is a nitpick rather than an issue. Evicting by insertion order removes the doubt.
nitpick (non-blocking): a stale count outlives the failure it counted
.../LDAPReplicationDomain.java:2700 — failedReplayAttempts.remove() exists at exactly two sites:
:2057 (result == SUCCESS) and :2647 (skipUnreplayableChange). recoverFromReplayFailure returns
early at the shutdown || disabled guard and at the lost-CAS path after countReplayFailure() has
incremented, and the NO_OPERATION path never removes. Counts survive a disable/enable cycle, so a CSN
that burned 2 attempts before an import is skipped on its first failure afterwards — against a backend
that is healthy again.
nitpick (non-blocking): a dropped delivery skips processUpdateDone() entirely
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/ReplayThread.java:102
if (!domain.markInProgress(updateMsg))
{
continue;
}No ack is owed — the delivery necessarily arrived on a session already torn down, and rcvWindow is
reset on reconnect. But incProcessedUpdates() is skipped, so replication-processed-updates
undercounts, and ReplicationBroker.updateDoneCount is not reset at reconnect the way rcvWindow is,
so a residual carries into the new session.
nitpick (non-blocking): both give-up tests race the counter they assert
.../LDAPReplicationDomain.java:2645
failedReplayAttempts.remove(csn);
if (updateError(csn))
{
numFailedReplayedUpdates.incrementAndGet();updateError(csn) is what makes cover(csn) true, and both tests poll on exactly that before asserting
replayed-updates-failed. In the two-CSN test one thread's commit() head-walk pushes both CSNs
while only one increment has landed → assertEquals(..., initialFailures + 2) fails. Increment before
updateError.
nitpick (non-blocking): ShortCircuitPlugin state can silently defang the transient test
opendj-server-legacy/src/test/java/org/opends/server/plugins/ShortCircuitPlugin.java:739
deregisterShortCircuit() clears shortCircuits and shortCircuitLimits but not shortCircuitCounts;
the 3-arg registerShortCircuit() clears neither. More importantly the registration is keyed only on
operationType + section and is consulted for every delete in the JVM, so unrelated deletes can eat
the maxTimes=3 budget of transientReplayFailureIsRetriedAndTheChangeApplied — eat all three and the
replayed delete succeeds on its first attempt with every assertion still green. The other five callers
use the request-control path and are unaffected.
note (non-blocking): two behaviour changes not in the description
.../LDAPReplicationDomain.java:2473—ConflictResolution.FAILEDnow setsreplayErrorMsgand
callsskipUnreplayableChange(), soOBJECTCLASS_VIOLATION,CONSTRAINT_VIOLATION,
INSUFFICIENT_ACCESS_RIGHTS,ADMIN_LIMIT_EXCEEDEDraise theUnreplayedChangealert and fail the
SAFE_READ ack after one try, with no retry budget. Master only did
logger.error(ERR_ERROR_REPLAYING_OPERATION, ...). Probably right — worth stating..../LDAPReplicationDomain.java:2407—NO_OPERATIONnow doesreplayDone = true; updateError(csn);.postOperationcommits only onSUCCESS, so previously such a change stayed
uncommitted forever and blocked the prefix push. A real fix for a state stall, in neither commit
subject.
chore (non-blocking): four small ones
!replayFailedin thewhileat:2366is dead — the field is assigned only after the loop and in
the catch.waitBeforeSessionRestart()(:2802) catchesInterruptedException, re-sets the flag, and returns as
if it had waited;enableService()then runs on an interrupted thread and the backoff is skipped.resetUnreplayedChangeAlertThrottle()(:2678) ispublicon a production class for a test in the
same package — package-private reaches it.RemotePendingChangesTestextendsReplicationTestCase: a full server start/stop for six in-memory
unit tests. Nothing covers the eviction bound.
…y instead of forgetting it The recovery from a failed replay forgot every uncommitted change, including the ones another replay thread was applying at that moment: their commit() then failed, the ServerState stayed behind them, the replication server delivered them a second time and their dependency ordering was lost, so a child entry could end up renamed to a conflict RDN for good. A change whose replay failed is now released rather than forgotten. Ownership is a mark on the change itself: it stays listed as the uncommitted barrier which holds the ServerState back, it stays a dependency of the changes which follow it, and the delivery which comes next takes over from the one which failed. The changes another thread is applying are left alone and commit as usual. - ask for another session restart when a change is released while one is under way, or its delivery is turned down as a duplicate and nothing asks again - give a change back to the replication server when the replay thread which owns it stops, and restart the session for it: nothing else would ask for it, and the ServerState would stay behind it for good - register a dependency under the pending changes lock, and guard firstKey(): an entry in dependentChanges which pendingChanges does not list made getNextUpdate() throw for the life of the domain - give up on a change after it has been failing for five minutes rather than after three attempts: a backend being rebuilt or restored is measured in minutes. The backoff counts the session restarts of the domain, not the failures of whichever change opened the recovery, and is capped at ten seconds because it runs on a replay thread every domain of this server shares - stop and start the session under the same lock as disable(), enable() and shutdown(), and only start back a session which is still the one this thread stopped - keep BUSY out of the give up: ten yields are spent in no time under lock contention, and the change is as absent from the data as after a storage failure - let solveNamingConflict() keep the two result codes it solves on a ModifyDN - forget the pending changes once the listener is stopped rather than while it can still list one, count a dropped delivery as processed, and publish PendingChange.msg safely - measure the give-up budget and the alert throttle on a clock which only moves forward
|
Thanks - the review holds where it counts, and one of the two blockers turned out to be the
|
maximthomas
left a comment
There was a problem hiding this comment.
praise: The redesign is the right call, and the counter-argument to the round-2 suggestion holds: nothing removes a change from activeAndDependentChanges when its replay fails, so "keep the listed ones" would have stranded the change that opened the recovery — its redelivery refused, nobody left to replay it. The watermark point is right too; keeping a newer in-flight change while dropping the older failed one is #889 by another route. Explicit ownership beats inferring it from map membership. sessionRestartRequested closes a hole the review never raised, and disable() clearing after disableService() is correct as argued. Four candidates from this round died on tracing because the code was already right in non-obvious ways: the dropped delivery owes no ack and leaks no window credit (rcvWindow is reset at every handshake); replayFailed() cannot see a committed CSN, because every commit path sets replayDone and breaks; a stopping thread's zero-delay restart cannot coexist with a requester owed a backoff; and abandonReplay() cannot deadlock stopReplayThreads(), since nothing on that path takes the class monitor.
issue (blocking): ReplayFailures eviction restarts the give-up budget, so a large outage never gives up.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/ReplayFailures.java:96-129
Runs leave the map only via forget()/clear() — success, give-up, shutdown/disabled — so every surviving entry is a change that is still failing. The eldest is not "stopped failing longest ago", it is the still-failing change that has waited longest for its next turn. Evict it and its next failure allocates a new Run with firstFailureTimeMs = nowMs, resetting the clock.
new LinkedHashMap<CSN, Run>(16, 0.75f, true) { removeEldestEntry -> size() > maxTracked }With more than MAX_FAILED_REPLAY_ATTEMPTS_TRACKED (1000) distinct changes failing, every change is evicted between two of its own failures, so failure.getFailingForMs() >= replayGiveUpDelayInMs (LDAPReplicationDomain.java:2857) is never true for any of them: no give-up, no ERR_REPLAY_SKIPPING_CHANGE, no alert, a session restart every ≤10 s forever. Reachable — the shared replay queue is LinkedBlockingQueue<>(10000) (MultimasterReplication.java:109) and window-size defaults to 100000, ten times the bound.
Suggested: carry firstFailureTimeMs on the PendingChange that is already the barrier, and leave only the attempt count in ReplayFailures. If the side map stays, eviction must drop the run's detail and keep its deadline. The class javadoc at :33-38 also states an invariant that cannot hold — a change that stopped failing has already been forget()-ten.
issue (blocking): The give-up releases the change before it decides to give up, so it can commit a change another thread is applying.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2854-2863
remotePendingChanges.replayFailed(csn);
final ReplayFailures.Failure failure = replayFailures.recordFailure(csn, monotonicNowInMs());
if (failure.getFailingForMs() >= replayGiveUpDelayInMs)
{
final LocalizableMessage message =
ERR_REPLAY_SKIPPING_CHANGE.get(csn, getBaseDN(), failure.getAttempts());
logger.error(message);
skipUnreplayableChange(csn, message);
return false;
}Between the release and the commit the change is listed, uncommitted and unowned — exactly what putRemoteUpdate() accepts as a takeover (RemotePendingChanges.java:175-180), and nothing re-checks isOwned(). T1 un-owns A, then sits in the synchronized recordFailure and a logger.error — both slow in the scenario that produced a 5-minute run. Another thread restarts the session, the RS resends A (the ServerState is still held back by A), a third thread applies it. T1 resumes and commits A: the watermark advances over a change being applied right then. If that thread's replay then fails, replayFailed(A) is a no-op because A is committed — A is in the ServerState, absent from the data, no alert.
Suggested: record the failure and test the budget before replayFailed(csn) — skip while still owned, or release to be retried, never both.
issue (blocking): abandonReplay acks the delivery as successfully replayed.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2440-2447
if (replayThreadShutdown.get() || shutdown.get()) // master: shutdown.get() only
{
abandonReplay(msg.getCSN());
return; // -> finally: processUpdateDone(msg, replayErrorMsg)
}dependency is the enclosing while-condition and replayErrorMsg is still null, so ReplicationDomain.java:3465 publishes a plain new AckMsg(csn). An assured SAFE_READ writer is told the change is in the data at the moment this replica declares it absent and asks for it again. Master reached this only on server shutdown; the replayThreadShutdown disjunct makes it fire whenever num-update-replay-threads is reconfigured on a live domain. This is the opposite polarity to the false negative the description defends.
Suggested: set replayErrorMsg before the return, or suppress the ack as markInProgress()'s drop path at :2394 already does.
issue (blocking): readFractionalConfig's restart is a fourth disableService()/enableService() pair and is not under serviceStateLock.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:792,819
if (needRestart) { disableService(); }
... // :794-814 mutate the fractional configuration
if (needRestart) { enableService(); }The lock is taken at :2365, :2954, :2973, :3668, :3715 — not here. A replay thread waking in restartSession() takes the uncontended lock, finds isListenerShuttingDown() true (the config thread already nulled listenerThread), so its guard does not fire and it runs enableService(). The session is up mid-reconfiguration: setFractional() has run, the exclusive flag and the attribute sets at :801-813 have not. The inner synchronized (sessionLock) does not help — it makes each call atomic, not the pair.
Also a description fix: "disable(), enable() and shutdown() now serialise with the recovery on one lock" is true of those three and not of this pair.
issue (non-blocking): restartSession() guards only its enable side.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2954-2962 vs :2975
if (shutdown.get() || disabled) { return; } // teardown: no identity check
disableService();
...
if (shutdown.get() || disabled || !isListenerShuttingDown()) { return; } // enable: has onedisable() never clears sessionRestartRequested, so after an import the loop at :2914 reads a leftover request and the teardown block kills the session enable() just restored — for a redelivery that cannot come, clear() having forgotten every pending change. Bounded by MAX_REPLAY_RETRY_DELAY_IN_MS and self-healing, but it falsifies "the recovery only starts a session back if the session it stopped is still the one which is down": nothing records which session it stopped.
Suggested: clear sessionRestartRequested in disable(); properly, a session generation counter captured before the wait and checked on both sides.
issue (non-blocking): The "applied exactly once" assertion cannot detect a double apply.
opendj-server-legacy/src/test/java/org/opends/server/replication/UpdateOperationTest.java:1662-1665,1693
assertMonitorAttrValueEventually("replayed-updates-ok", initialReplayed + 1,
"a change which was delivered again must be applied exactly once");
assertMonitorAttrValueEventually("replayed-updates-failed", initialFailures, ...);The helper is TestTimer.repeatUntilSuccess, which returns on the first passing poll and never re-checks — a change applied twice passes as long as the counter transits through +1, which is the OPENDJ-1115 regression the takeover exists to prevent. The second call expects the value already read into initialFailures, so it succeeds at t=0: a snapshot, not a wait.
Suggested: assert stability after a settle, or assert on a value only reachable once.
issue (non-blocking): RemotePendingChangesTest lost its TestNG groups.
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RemotePendingChangesTest.java:35
public class RemotePendingChangesTest extends DirectoryServerTestCase // was ReplicationTestCaseReplicationTestCase carries @Test(groups = { "precommit", "replication" }); the new parent does not, and the methods use a bare @Test. Failsafe's filename include still runs the class, so any group-filtered invocation silently stops running the #889 regression test.
todo (non-blocking): Three behaviour changes announced in the description have no test: the BUSY reclassification (LDAPReplicationDomain.java:2593), the solveNamingConflict(ModifyDNOperation) exclusion-set additions, and abandonReplay()'s hand-back (:2935). StateMachineTest changes the replay-thread count but asserts nothing about the hand-back.
issue (non-blocking): disable() can drop a change a replay thread is mid-apply.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:3668-3684
The replay path takes serviceStateLock nowhere and disableService() joins only the listener, so a thread can be between markInProgress() and commit() with the operation already in the backend when state.save() persists a watermark excluding it and clear() wipes it. Pre-existing at 0b9c0f63f5 (which had no clear() there) — the PR turns a silent double replay into a logged ERR_OPERATION_NOT_FOUND_IN_PENDING. Worth a follow-up issue rather than a fix here.
issue (non-blocking): Two monitor attributes changed meaning silently.
changes-in-progress-size—replayFailed()never removes fromactiveAndDependentChanges(onlycommit()andclear()do), so a failed change counts as "actively being replayed" until redelivery or give-up.RemotePendingChanges.java:250-262, published atLDAPReplicationDomain.java:4875.replication-processed-updates— the dropped-delivery path callsincProcessedUpdates()(LDAPReplicationDomain.java:2404), so it now counts deliveries taken off the session, not updates handed to the replay path. Not in the doc diff.
question (non-blocking): Widening CONFLICT_RESULT_CODES also changes isServerFailure(), its only reader (LDAPReplicationDomain.java:407-412,2726-2733). With ds-cfg-server-error-result-code = 53 or 65, does a genuine backend failure now route to solveNamingConflict(), return FAILED for non-ModifyDN ops and land in skipUnreplayableChange() — recorded as replayed after one attempt? Deliberate?
nitpick (non-blocking): ERR_REPLAY_SKIPPING_CHANGE_308 says "after %d attempts" but the trigger is a duration; the count printed is not the threshold that fired. opendj-server-legacy/src/messages/org/opends/messages/replication.properties:616
nitpick (non-blocking): putRemoteUpdate()'s takeover tests !isCommitted() && !isOwned() while its javadoc and LDAPReplicationDomain.java:4822 both call it "a change whose replay failed" — it also matches a change between putRemoteUpdate() and markInProgress(). Benign today; the comment overstates the guard. opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:175-180
nitpick (non-blocking): disable() and the recovery bail-out clear replayFailures but not sessionRestartRequested or consecutiveSessionRestarts, contra "stale counts … dropped when the recovery bails out and when the domain is disabled".
…lf, and decide before releasing it The failures of each change lived in a bounded map on the side, ordered on the last failure. Only changes which are still failing are listed there - they leave it when they are replayed or given up on - so the eldest entry is the one whose turn to fail has not come round yet, and evicting it restarted its budget: with more changes failing than the bound, no change ever reached the give up and the replica restarted its session every ten seconds for good. The failures live on the PendingChange which is already the barrier now, so they are kept for as long as the change is not in the data - whichever delivery is replaying it - and they go away with it. ReplayFailures is gone with its eviction, and with it the CodeQL alert on the size() its anonymous LinkedHashMap shadowed. The failure is recorded and the budget tested while the replay thread still owns the change: releasing it first left it listed, uncommitted and unowned, which putRemoteUpdate() takes over, so another thread could be applying the change while this one recorded it as skipped. A replay thread which is stopped - the number of them is being changed - no longer acks the delivery as replayed: the ack reports the error, and the change is given back to the replication server only once that ack has been published, since handing it back stops and starts the session the delivery came over. readFractionalConfig(), changeConfig() and readAssuredConfig() stop and start the session around a configuration change, none of them under the lock the recovery from a failed replay holds: a replay thread waking up brought the session up in the middle of a reconfiguration, reading a configuration half way through being changed. They run under that lock now, and the recovery only starts a session back if it is still the one it stopped - a generation counter which every stop and start under the lock bumps, plus the listener still being down. disable() and the give up also clear the recovery state, which otherwise had the change failing next wait the longest backoff. A change which comes back FAILED on the configured server-error-result-code is retried rather than recorded as replayed - conflict resolution having a claim on that code only means it is given its chance first - and it is retried in place as many times as an unavailable backend is, so a storage busy for a moment does not cost a session restart. Tests: the ReplayFailuresTest scenarios move to RemotePendingChangesTest, against the real bookkeeping and with a change which keeps its budget while 1500 others fail in between; transientReplayFailureIsRetriedAndTheChangeApplied covers BUSY as well as UNAVAILABLE and waits for its counters to settle, so a change applied twice fails it; changeConflictResolutionCanNotSolveOnTheServerErrorCodeIsRetried covers the result code above. UpdateOperationTest 13/13, RemotePendingChangesTest 12/12, AssuredReplicationPluginTest 14/14, StateMachineTest 5/5.
|
Thanks - every blocker holds, and two of them turned out to have the same fix. Everything is in 5a0297e. One finding is answered rather than applied; details below.
|
There was a problem hiding this comment.
praise: The rework is the right shape, and three things stand out.
Deleting ReplayFailures instead of raising its bound. The eviction was a symptom — every entry in
that map was a change still failing, so the eldest was just the one whose turn had not come round.
Moving the budget onto PendingChange takes the eviction, the staleness heuristic and the growth
question with it in one move.
Re-doing the abandon fix after the first attempt. Setting replayErrorMsg where the old return
was looked right; catching that the hand-back stops and starts the session, so the error ack was
being published on a session that had just been torn down, needed re-reading the path rather than
trusting the diff. The flag-and-break shape is correct.
aFailingChangeKeepsItsBudgetHoweverManyOtherChangesAreFailing fails 1500 changes against an old
bound of 1000, and changeConflictResolutionCanNotSolveOnTheServerErrorCodeIsRetried genuinely
fails on the previous head. Both are real regression tests, not restatements of the new code.
Self-reporting the recordReplayFailure()-returns-null-for-a-committed-change case, found while
re-reading rather than by a test, is the same instinct.
issue (blocking): Giving up on a change resets the session-restart backoff for every other
change still failing.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2846
consecutiveSessionRestarts is domain-wide and is the only input to waitBeforeSessionRestart() —
min(1000ms * restarts, 10000ms), so a 1s floor climbing to a 10s cap after ten restarts.
private void skipUnreplayableChange(CSN csn, LocalizableMessage cause) {
if (updateError(csn)) {
numFailedReplayedUpdates.incrementAndGet();
sendUnreplayedChangeAlert(cause);
...
consecutiveSessionRestarts.set(0); // <-- here
}
}Backend goes UNAVAILABLE. The backoff climbs correctly for 300s. Then the head change's budget
expires, skipUnreplayableChange runs, and the counter goes to 0 — while the whole backlog is still
failing. Those changes restart the session from the 1s floor again. Give-ups then recur at the
write-arrival rate, and one per 55s is enough that the cap is never reached: a sustained outage
restarts the session every ~1-2s instead of every 10s, each restart rewinding the RS cursor to the
frozen ServerState and re-streaming the backlog. The field's own javadoc at 364-370 names this
failure mode as the reason it exists.
It is also reached from 2598 (FAILED) and 2656 (LOOP), so one thread skipping an unrelated
change resets a backoff another thread is climbing.
Giving up is not progress — the backend is still down. Delete the line; 2119 (postOperation)
already resets on a change that actually applied.
issue (blocking): serverFailedOnAConflictResultCode is set once and decides the fate of all
ten attempts.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2583
The flag is declared once per delivered message (2436), set inside the retry loop, never cleared,
and tested after it:
// 2583, inside case FAILED:
serverFailedOnAConflictResultCode = true;
Thread.sleep(50);
break;
// 2632, after the loop
if (isServerFailure(lastResult) || ResultCode.BUSY.equals(lastResult)
|| serverFailedOnAConflictResultCode)With ds-cfg-server-error-result-code = UNWILLING_TO_PERFORM — a CONFLICT_RESULT_CODES member,
which is this branch's whole premise. Attempt 1 returns it, solveNamingConflict(Modify) falls to
the terminal FAILED at 3310, flag set. Attempt 2 finds the entry renamed concurrently:
NO_SUCH_OBJECT, findEntryDN hits, msg.setDN, REPLAY_AGAIN. Attempts 3-10 ping-pong
REPLAY_AGAIN until retryCount is spent. lastResult is NO_SUCH_OBJECT — neither a server
failure nor BUSY — but the stale flag still takes the server-failure road: withheld from the
ServerState, session restarted, re-delivered every round for the full 300s before being skipped
anyway. The intended road at 2651-2656 never runs, so unresolved-naming-conflicts stays flat for
what is exactly an unresolved naming conflict, and ERR_ERROR_REPLAYING_OPERATION at 2640 names
NO_SUCH_OBJECT rather than the code that set the flag.
Drop the flag and test the last attempt's own result, so the branch and the log agree:
if (isServerFailure(lastResult) || ResultCode.BUSY.equals(lastResult)
|| isConfiguredServerErrorResultCode(lastResult))issue (blocking): The "exactly once" fix landed at one of three sites; the two give-up tests
still cannot catch the regression their own messages name.
opendj-server-legacy/src/test/java/org/opends/server/replication/UpdateOperationTest.java:1486
and :1576
assertMonitorAttrValueEventually is a TestTimer.repeatUntilSuccess — the first poll that matches
wins:
// 1486
assertMonitorAttrValueEventually("replayed-updates-failed", initialFailures + 1,
"a change which could not be replayed must be counted once, not once per attempt");
// 1576
assertMonitorAttrValueEventually("replayed-updates-failed", initialFailures + 2,
"both changes must be counted as failed, once each");A counter ending at +2 passes through +1, and whichever 200ms poll lands on the transit value
satisfies the assertion. The likeliest break — a retry loop incrementing per attempt — is precisely a
counter climbing past the expected value, so these two tests are blind to it. The correct pattern is
already three lines below at 1687-1689:
assertMonitorAttrValueEventually("replayed-updates-failed", initialFailures + 1, "...");
assertMonitorAttrValueStays ("replayed-updates-failed", initialFailures + 1, "...");Worth sweeping the construct rather than these two lines — it survived at two of the three places it
appears.
suggestion (non-blocking): The flagship conflict test does not pin conflict-resolution-first.
opendj-server-legacy/src/test/java/org/opends/server/replication/UpdateOperationTest.java:1743-1800
changeConflictResolutionCanNotSolveOnTheServerErrorCodeIsRetried asserts only that the change is
retried and applied. Putting UNWILLING_TO_PERFORM back into isServerFailure() — never calling
conflict resolution at all — gives identical observables. Assert that solveNamingConflict was
reached (unresolved-naming-conflicts, or a conflict side effect).
suggestion (non-blocking): The isCommitted() half of the new null guard is untested.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:332
if (change == null || change.isCommitted())In replayFailuresGoAwayWithTheChange the committed CSN is the first generated, so commit()
flushes it off the head of the map and the null comes from change == null. Deleting
|| change.isCommitted() keeps every test green. The case named in your comment — the give-up
commits a change sitting behind an older uncommitted one — has no test. Commit the second change
while the first is still uncommitted.
issue (non-blocking): sessionGeneration's documented invariant is false.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:385-391
Javadoc says "Bumped every time the session of this domain is stopped or started". It is bumped at
798/826, 2375, 3056/3084, 3774, 3839 — but not around changeConfig() or
readAssuredConfig(), both of which stop and start the session
(ReplicationDomain.java:3378, :3838-3843). Not live today, since both sides now hold
serviceStateLock. But dropping the isListenerShuttingDown() half of the 3071 guard on the
strength of that javadoc would reintroduce the double-start this commit fixes. Either bump it in
those two paths or narrow the javadoc.
question (non-blocking): Was the peer-visible cost of abandoning on a live thread-count change
intended?
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2452-2466
The polarity fix is right. But the condition is replayThreadShutdown.get() || shutdown.get(), and
MultimasterReplication:767-771 does stopReplayThreads(); createReplayThreads(); on a
ds-cfg-num-update-replay-threads change. So a benign tuning action now makes every in-flight
assured SAFE_READ change ack with setHasReplayError(true): the origin logs
NOTE_DS_RECEIVED_ACK_ERROR and bumps assuredSrReplayErrorUpdates, for a change this replica
applies moments later. A permanent false negative on the origin.
Either let a thread finish the change it owns before exiting, or accept it and say so in the
javadoc.
issue (non-blocking): abandonReplay() is silent when the domain is disabled.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:3028-3037
if (shutdown.get() || disabled) { return; }
// ...comment justifying the shutdown case only
logger.info(NOTE_REPLAY_ABANDONED_CHANGE, csn, getBaseDN());A change abandoned during an import/restore is handed back with no local log line, while the ack
still carries the error. The peer records a replay error the local log never explains. Log it, or
extend the comment.
suggestion (non-blocking): The two give-up messages misreport attempts and duration.
opendj-server-legacy/src/messages/org/opends/messages/replication.properties:613-621,
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2938-2939
getAttempts() counts delivery rounds; each round already burned up to
IN_PLACE_REPLAY_ATTEMPTS = 10 in-place tries. Both WARN_REPLAY_RETRYING_CHANGE_307 and
ERR_REPLAY_SKIPPING_CHANGE_308 print it as (attempt %d), understating by up to 10x. And:
ERR_REPLAY_SKIPPING_CHANGE.get(csn, getBaseDN(), failure.getFailingForMs() / 1000, ...)truncates, so a sub-second give-up delay — which is how the tests set it — prints "failing for 0
seconds". Say "delivery", and print sub-second durations in ms.
note (non-blocking): The TestNG-groups fix changes nothing in this build, but keep it.
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RemotePendingChangesTest.java:35
There is no <groups>/<excludedGroups> in any surefire/failsafe config, no testng*.xml, and no
-Dgroups in any workflow — selection is by **/*Test.java filename only, so the class was already
running and a normal build never noticed its absence from the groups. The annotation still earns its
place: the class extends DirectoryServerTestCase, not ReplicationTestCase, so without it any
group-filtered invocation — -Dgroups=replication, an IDE group run, downstream CI that filters —
silently skips the regression test for the issue this PR exists to fix. sequential = true is inert
under <parallel>none</parallel>, and as a class-level @Test attribute it would not be inherited by
the methods' own @Test annotations if parallelism were ever enabled.
todo (non-blocking): The test story overstates coverage.
git grep -l "sessionGeneration\|ABANDONED_CHANGE\|readFractionalConfig\|abandonReplay" over
opendj-server-legacy/src/test returns nothing. Round-1 fixes #4 (readFractionalConfig under
serviceStateLock) and #5 (the sessionGeneration guard) ship untested; the comment acknowledges
only #3. #5 is a generation counter compared across a wait — exactly the shape a unit test pins.
nitpick (non-blocking): Six small ones.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:112-122—
changes-in-progress-sizenow counts a failed change for the whole give-up window with nothing
actively replaying it. The new meaning is in javadoc only; the doc delta adds the alert type but
not this. Dashboards only — no flow control reads it.opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PendingChange.java:44-56—
committed,owned,replayFailures,firstReplayFailureTimeMscarry no@GuardedBy. All five
write sites do holdpendingChangesWriteLock, so no live race; the annotation is what stops the
next caller adding one.opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RemotePendingChangesTest.java:362—
the magic1500no longer relates to any surviving constant (MAX_FAILED_REPLAY_ATTEMPTS_TRACKED
= 1000 went withReplayFailures). Valid today; a future bound above 1500 reintroduces the bug
green. Worth a comment saying why 1500.opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/RemotePendingChangesTest.java:328—
the re-homed forget test records one failure where the deletedaChangeWhichIsForgottenStartsOver
recorded two and asserted the count restarted at 1. Aclear()resetting to 1 instead of 0 now
passes.opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2662-2680—
thecatch (Exception e)does not restore the interrupt flag, unlike2388and3109in the
same file.opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PendingChange.java:186-189—
getReplayFailingForMs()cannot distinguish "never failed" from "failed just now"; both return 0.
Dead guard on the only call path, latent for a future caller.
…up, and decide on the last attempt Giving up on a change reset consecutiveSessionRestarts, the domain-wide backoff between the session restarts a failed replay asks for. A backend outage fails every change in flight and their give-up budgets expire in turn, so each give-up sent the backoff back to its one second floor while the rest of the backlog was still failing: a sustained outage restarted the session every second or two rather than every ten, each restart rewinding the replication server to the ServerState the failed changes hold back and re-streaming the backlog. Giving up is not a change being replayed - the reset belongs where one is applied, which is where postOperation and recordChangeResolved already do it. serverFailedOnAConflictResultCode was set once per delivery, never cleared, and read after the retry loop, so an attempt which failed on the configured server-error-result-code decided the fate of the nine which followed it. A change whose later attempts came back on something conflict resolution kept rewriting took the server-failure road all the same: withheld from the ServerState and asked for again for the whole give-up window rather than reported as the unresolved naming conflict it is, with ERR_ERROR_REPLAYING_OPERATION naming a result code which is not the one the branch acted on. The result of the attempt which spent the last retry is what decides now, so the branch and the log agree. The two give-up tests read their counter with a timer which stops at the first poll that matches, and a counter bumped once per attempt rather than once per change passes through the expected value on its way up: both now assert that the value stays put, the way the transient-failure test already did. Also: ERR_REPLAY_SKIPPING_CHANGE printed "0 seconds" for any give-up under a second and both messages called a delivery an attempt, where each delivery is attempted ten times in place; and the sessionGeneration javadoc claimed to count every session this domain stops and starts, which changeConfig() and readAssuredConfig() do without it - the guard in restartSession() reads the listener as well for exactly that reason.
|
Thanks - all three blockers hold, and the first one is a straight reversal of what I argued last round. Fixed in 3c0f24f, together with two of the smaller findings. Six are acknowledged and left; details and reasons below, so that what is not in the commit is not in it by decision rather than by omission. Giving up resetting the session backoff (blocker) - fixed, and I had it backwardsYou are right, and this is the finding I should not have needed twice: last round I defended that reset with "the ServerState moved past the change, so the change which fails next must not inherit the backoff". That reads well and is wrong. Giving up is not progress - the backend which made this change unreplayable is still failing every change in flight with it - and the field's own javadoc names the failure mode: a change delivered for the first time keeping the wait at its shortest for as long as the outage lasts. Your arithmetic is the whole argument, and I had written it myself: the ramp is 1+2+...+10 = 55s, so a give-up more often than once a minute means the ten second cap is never reached, and give-ups arrive at the write rate once the first budget expires. The line is gone. What resets the backoff now is a change which was really applied - Your second point holds too: the reset was reached from the
|
…ed, and read the backoff off the changes which are failing A message this replica can not turn into an operation was left listed, uncommitted and owned by the replay thread which failed on it. Nothing retries it - there is no operation to retry and no delivery would decode any better - so it stayed the barrier which holds this domain's ServerState, and every change which follows it from every master, back for good; the delivery which would replace it is turned down while a replay thread still owns it. The wedge is as old as the decode path, but master had an accidental way out: putRemoteUpdate() overwrote the listed copy, so an unrelated redelivery let the listener push the CSN through. Ownership closes that, so the change is now skipped where it is reported - counted, alerted on, and out of the way of everything behind it. The session restart backoff was reset by every successful replay on the domain, which is only news when the change which was failing is the one that succeeded. A change which can never be applied here fails alone, among changes which replay perfectly well, so a single poison change had its domain tear the session down and rebuild it once a second for the whole give-up window. RemotePendingChanges now counts the changes with a replay failure recorded against them, and the backoff is reset only when none is left: an outage still resets it on the first change which gets through, and a poison change lets it climb to its cap. Also from this round of review: the in-place retry loop now leaves a domain which was disabled for an import instead of applying changes into the backend the import owns and failing every commit on the map disable() cleared; each attempt runs an operation of its own, rather than stacking one ManageDsaIT control and one access log record per attempt onto the operation which already ran; the ack javadoc says which of the two dropped-delivery cases owes no ack for which reason; and serviceStateLock says what holding it across the reconnect costs the callers which wait on it. Tests: an undecodable change must not hold the ServerState back; a change stays failing while the changes around it are replayed; and failures are counted once per change and go away with it. Each was checked against a mutation of the code it covers. The assured replay-error test gives up at once rather than restarting its session against a FakeReplicationServer while the test tears down, and the server-error-result-code test puts back the code it found rather than the default.
|
A second commit, A change which can not be decoded wedged the domain for good
The catch is identical in The session restart backoff was reset by every successful replayLast round you had me delete the reset in
Smaller, same commit
Tests, and how much they are worthThree new ones, and each was checked against a mutation of the code it covers rather than taken on trust - twice now you have found tests here which pass against the bug they name, so it seemed the least I could do:
The first builds a Worth saying plainly: the third one passed against the first two mutations. I only caught that by working out what each mutation actually does to it, which is why it has a mutation of its own rather than sharing theirs. Two test fixes came out of the same pass.
|
maximthomas
left a comment
There was a problem hiding this comment.
praise: The two bugs in 27e29b9 are both real, and the reasoning behind the first one is the
good part. Spotting that master only escaped the decode wedge by accident — putRemoteUpdate()
overwriting the listed copy unconditionally, so an unrelated failing change was what pushed the CSN
through — and that this PR's ownership check is what closes that door, is the kind of thing a diff
review does not surface. The backoff hole found from the other side (one poison change among healthy
replays, each of which was resetting consecutiveSessionRestarts) is a genuine second route to the
same hammering. Fresh operation per attempt kills the stacked ManageDsaIT control and the ten
access-log records. And reporting that the third test passed against the first two mutations, rather
than quietly giving it a mutation of its own, is worth more than the test.
issue (blocking): logDecodingOperationError() has two callers, and the give-up is only correct
for one of them.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/LDAPReplicationDomain.java:2800
// logDecodingOperationError(), :2795-2802 — new in 27e29b9
skipUnreplayableChange(msg.getCSN(), message);Caller two is the generic catch, reached whenever csn is still null — and csn is assigned at
:2544, 66 lines into the try:
// :2736-2737
catch (Exception e) {
if (csn != null) { ... replayFailed = true; }
else { replayErrorMsg = logDecodingOperationError(msg, e); } // any unchecked throw before :2544
}Reachable thrower, :2538 — ModifyOperationBasis.getEntryDN() returns null after an unparseable
DN (it catches LocalizedIllegalArgumentException, sets INVALID_DN_SYNTAX, returns null):
if (modifyOperation.getEntryDN().equals(SET_PERMISSIVE_MODIFY_FOR_DN)) // NPEThat NPE now runs skipUnreplayableChange → updateError → commit() → state.update(csn). The
ServerState covers a change that never reached the backend, replayFailed stays false so
recoverFromReplayFailure (:2765) never runs — no retry, no redelivery, no give-up budget — and
the ack still says ERR_EXCEPTION_DECODING_OPERATION. The origin server is told it failed; this
server's state says it was applied. At the previous commit this method only logged.
Move the skip into the DecodeException | LDAPException | DataFormatException catch at :2719, the
only caller for which the new javadoc's "no delivery would decode any better" is true. Leave the
csn == null path logging, or route it through the normal failure road.
todo (non-blocking): the guard this commit adds to commit() is not pinned by any of the three
new tests.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/RemotePendingChanges.java:243-247
if (pendingChange.getReplayFailures() > 0) { failingChanges--; }
it.remove();Drop the guard and all three pass. In aChangeKeepsFailingWhileTheChangesAroundItAreReplayed the
closing commit(failing) drains failing(1) and replayed(0): real code 1 → 0, mutant
1 → 0 → -1, and the closing assertFalse reads failingChanges > 0, false for both. In
failingChangesAreCountedOnceAndForgottenWithTheChange every removed change has failures, so the two
are identical. The decode test records no failure at all. A negative counter is not harmless:
hasFailingChanges() then reads false while a change is genuinely failing, restoring the
once-a-second restart loop this commit exists to stop.
Same test, RemotePendingChangesTest.java:416 — the middle assertTrue is vacuous. commit(replayed)
marks it committed, then breaks at the uncommitted head failing (:233-236), never reaching the
decrement. The counter is still the 1 written on line 409, so :416 re-asserts :410.
One line closes both:
// after the closing assertFalse(...)
// real code: 0 + 1 = 1 -> true ; guard dropped: -1 + 1 = 0 -> false
pendingChanges.recordReplayFailure(/* a fresh listed change */);
assertTrue(pendingChanges.hasFailingChanges());todo (non-blocking): setReplayGiveUpDelay(0) took the assured test off the road the PR is about,
and the comment left behind describes the old road.
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/AssuredReplicationPluginTest.java:1224
With the delay at 0 the first failure gives up — recordReplayFailure sets
firstReplayFailureTimeMs = now, so getFailingForMs() is 0:
// LDAPReplicationDomain.java:3017
if (failure.getFailingForMs() >= replayGiveUpDelayInMs) { // 0 >= 0, first failure
...
skipUnreplayableChange(csn, ...);
return false; // before replayFailed(:3033), sessionRestartRequested(:3054), restartSession
}So the retained comment at :1237-1240 — "the domain restarts its session to have the change sent
again, which takes its monitor entry away" — no longer holds; the monitor entry survives and those
assertions are now available. And the assured ack behaviour under a withheld change is no longer
covered by anything. Either keep 0 and rewrite the comment plus add the assertions it was excusing,
or use a short non-zero delay to keep the withhold road.
Fixes #889.
A change whose replay fails with anything other than
NO_OPERATION,BUSYorUNAVAILABLEis recorded as replayed: the ServerState advances past it, the replication server never sends it again, and an assured (SAFE_READ) ack goes back to the originating master as if the change had been applied. The replica silently diverges while reporting itself fully caught up, withunresolved-naming-conflictsat 0 and a single line in the error log. This is storage-agnostic: JE, PersistIt and JDBC all reach it on anyStorageRuntimeException.What changed
The four
solveNamingConflict()overloads no longer collapse two outcomes into onereturn true. They report aConflictResolution(REPLAY_AGAIN/NOTHING_TO_DO/FAILED), soreplay()can tell "the operation became a no-op after conflict resolution" from "the operation failed". The four copies of theERR_ERROR_REPLAYING_OPERATIONlog move to the single place which decides what to do with the failure.A failure of the server itself is no longer recorded as applied.
UNAVAILABLE,BUSYand theserver-error-result-code- the codeBackendImpl.createDirectoryException()puts on everyStorageRuntimeException, 80 by default - are retried in place, and if they keep failing the change is deliberately left out of the ServerState. The replication server still owns it, so the domain restarts its session and the change is delivered and replayed again on a backend which has hopefully recovered. The codes conflict resolution knows how to solve (NO_SUCH_OBJECT,ENTRY_ALREADY_EXISTS,NOT_ALLOWED_ON_RDN,NOT_ALLOWED_ON_NONLEAF, andUNWILLING_TO_PERFORM/OBJECTCLASS_VIOLATIONwhichsolveNamingConflict(ModifyDNOperation)solves too) are excluded from that test:server-error-result-codeis configurable and is not validated as a result code, and it must never take a change away fromsolveNamingConflict(). Conflict resolution getting its chance first is all that exclusion means, though: a change which comes backFAILEDon the configuredserver-error-result-codeis retried as the server failure it is, rather than recorded as replayed after one attempt - and retried in place as many times as an unavailable backend is, so a storage busy for a moment does not cost a session restart. Which road it takes once those attempts are spent is decided by the result of the attempt which spent the last of them rather than by an earlier one: a change whose later attempts kept coming back on something conflict resolution rewrote is the replay loop, and is reported and counted as one.A change this replica can never turn into an operation is given up on, not left in the way. A message which fails to decode has no operation to retry and no delivery which would decode any better, so it is skipped where it is reported - counted, alerted on, and out of the way. Left listed, uncommitted and owned by the replay thread which failed on it, it was the barrier holding this domain's ServerState - and every change behind it, from every master - back for good, since the delivery which would replace it is turned down while a replay thread owns it. The wedge is as old as the decode path; what is new is that ownership closes the accidental way out master had, where
putRemoteUpdate()overwrote the listed copy and an unrelated redelivery let the listener push the CSN through.A change whose replay failed is released, not forgotten. Ownership is a mark on the change itself.
replayFailed(csn)drops the mark and leaves everything else alone, so the change stays listed and uncommitted - it is the barrier which holds the ServerState back, which is what makes the replication server send it again - and it stays among the changes newer ones are checked against, because a change which is not in the data is exactly what the changes which follow it must depend on.putRemoteUpdate()then takes over an unowned change: the delivery which comes next is the one to replay, while a change a replay thread still owns is refused as the duplicate check of OPENDJ-1115 wants. The copy which the previous delivery left in the shared replay queue is dropped when a replay thread takes it out, becausemarkInProgress()only accepts the delivery which is listed as pending; it is counted as processed all the same, soreplication-processed-updatesdoes not drift.The changes another replay thread is applying at that moment are left alone: they commit as usual, and the head-of-line barrier keeps their CSNs - and the failed one - out of the ServerState until the failed change is applied. Forgetting them would have their
commit()fail, the ServerState stay behind them and the replication server replay them a second time, which is how a child Add resent while its parent is not in the data ends up renamed to a conflict RDN for good.Two things follow from keeping the pending changes:
putRemoteUpdate()no longer overwrites the copy which is listed with the one a new delivery came with (which lost the fact that it had been replayed), and a duplicate delivery no longer has the listener thread push its CSN to the ServerState - the copy which is listed owns the change and records it once it really has been replayed, while the ack and the window credit stay per delivery.Every failed replay is reported, including the one a replay thread abandons when it is stopped - the ack says the change is not in the data here, rather than being the plain ack master sent, and the change is given back to the replication server only once that ack has been published, since handing it back stops and starts the session the delivery came over.
replayErrorMsgis set on all failure paths, so the SAFE_READ ack carries the replay error instead of telling the originating master that the write is durable here. The ack belongs to the delivery and states what that delivery did: a change which failed is not in the data at that point, whether or not the delivery which follows manages to apply it. Holding it back until the change is resolved would not be more truthful - the session it came over is torn down a moment later, so nothing would reach the server waiting for it and an assured write would wait out its timeout instead of being told what happened. The newreplayed-updates-failedmonitor attribute counts the changes this replica gave up on - once each, likereplayed-updates-okcounts the ones it applied.A change which can never be applied here does not stop the replica for good. The failures are counted on the
PendingChangeitself - the change which stays listed as the barrier - so they are kept for as long as the change is not in the data, whichever delivery is replaying it, and they go away with it when it commits or when a disabled domain forgets it. A backend which is failing fails every change in flight, so a single counter would be reset by each of them in turn, and a bounded map on the side would have a change evicted between two of its own failures and its budget restarted. The budget is a duration rather than a number of attempts: whatisServerFailure()reports is measured in minutes, because a backend which is being rebuilt, imported into or restored (OPENDJ-49) serves nothing while it works, and a handful of attempts would have this replica give up on every change of a maintenance window it only had to wait out. After five minutes of failing, the change is skipped, but loudly:ERR_REPLAY_SKIPPING_CHANGEplus the newUnreplayedChangealert telling the administrator that this replica has diverged and must be reinitialized. The attempts in between are logged asWARN_REPLAY_RETRYING_CHANGE, and the session is left down for a moment before the change is asked for again, so that a backend which keeps failing is not asked for every change as fast as the replication server can send them. That backoff counts the session restarts of the domain rather than the failures of whichever change opened the recovery - with an outage failing everything in flight, a change being delivered for the first time would otherwise keep the wait at its shortest - and it is capped at ten seconds, because it runs on a replay thread every domain of this server shares. Giving up on a change does not start that backoff over either: whatever made that one unreplayable is still failing the changes in flight with it, so a replica which gives up on a change now and then would otherwise ask for the whole backlog of an outage at the shortest wait. Only a change which was really applied resets it, and only when it is the last one which was failing:RemotePendingChangescounts the changes with a failure recorded against them, because a change which can never be applied here fails alone, among changes which replay perfectly well. Resetting on any success at all had a single poison change tear the session down and rebuild it once a second for the whole give-up window, which is the hammering this backoff exists to prevent; an outage still resets it on the first change which gets through. Both messages count deliveries - each of which is attempted ten times in place - and the give-up prints how long the change has been failing in milliseconds, so that a give-up shorter than a second does not read as zero. Both durations are measured on a clock which only moves forward, so a wall-clock step does not decide whether a replica diverges.The session is stopped and started in one place.
disable(),enable(),shutdown(), the fractional configuration being changed and the recovery serialise on one lock, and every one of them bumps a session counter, so the recovery only starts a session back if the session it stopped is still the one which is down - a configuration change, or the end of an import, cannot be undone by a replay thread which was sleeping through it.readFractionalConfig(),changeConfig()andreadAssuredConfig()are held under that lock over the whole stop, mutate and start: a session brought up in between would read a configuration half way through being changed. The recovery checks both the counter and the listener still being down, since a restart made outside the lock leaves the counter untouched. A disabled domain also forgets its pending changes, once its listener is stopped rather than while it can still list one: its ServerState is saved and read again from the backend when it is enabled back, and a change which stayed listed would be discarded as a duplicate with nothing left to replay it. Finally, a replay thread which is stopped - when the number of them is changed - hands the change it was replaying back to the replication server instead of leaving it listed as owned by a thread which is gone.What deliberately did not change
Failures which are not the server's fault - a schema or constraint violation, or the conflict-resolution loop of #798 - keep being skipped, as they always were: redelivering them cannot help, and stopping the domain on them would turn a one-entry divergence into an outage. What is new for them is that they now carry the same honest ack, counter and alert, so the divergence is visible instead of silent: a
ConflictResolution.FAILED-OBJECTCLASS_VIOLATION,CONSTRAINT_VIOLATION,INSUFFICIENT_ACCESS_RIGHTS,ADMIN_LIMIT_EXCEEDED- now raisesUnreplayedChangeand fails the SAFE_READ ack after one try, where master only loggedERR_ERROR_REPLAYING_OPERATION. Restarting the session on those failures was tried first and is whatUpdateOperationTest.infiniteReplayLoopandnamingConflictsrightly rejected.Tests
RemotePendingChangesTest(new)RemotePendingChangesTest(new)UpdateOperationTest.failedReplayIsNotRecordedAsReplayed(new)UnreplayedChangealertUpdateOperationTest.everyChangeWhichCanNotBeReplayedIsGivenUpOn(new)UpdateOperationTest.transientReplayFailureIsRetriedAndTheChangeApplied(new)BUSY), for longer than the in-place attempts, then serves the operation, has its change applied exactly once - only reachable through a session restart and a second delivery - with nothing counted as failed and no alert raised. The exactly-once assertions read the counter until it settles, so a change applied twice fails them rather than passing on the way throughUpdateOperationTest.changeConflictResolutionCanNotSolveOnTheServerErrorCodeIsRetried(new)server-error-result-codeset to a code conflict resolution owns, a change which fails with it is retried and applied rather than recorded as replayed after one attemptUpdateOperationTest.aChangeWhichCanNotBeDecodedIsNotLeftHoldingTheServerStateBack(new)createOperation()- is skipped rather than left holding the ServerState back, counted once and alerted onRemotePendingChangesTest.aChangeKeepsFailingWhileTheChangesAroundItAreReplayed(new)RemotePendingChangesTest.failingChangesAreCountedOnceAndForgottenWithTheChange(new)AssuredReplicationPluginTest.testSafeReadModeReplyWithReplayError(new)hasReplayErrorandfailedServers=[1]Each of the three new tests was checked against a mutation of the code it covers, so that none of them is a restatement of the implementation: removing the give-up on the undecodable change, dropping the first-failure guard on the counter, and clearing the count on any commit each fail exactly one of them.
The
AssuredReplicationPluginTestone closes a TODO which has been in the tree since upstream: "make the domain return an error: use a plugin? The resolution code does not generate any error so we need to find a way to have the replay not working to test this...". The short circuit has to be set at the pre-parse plugin point -LocalBackendDeleteOperationand friends skip the pre-operation plugins for synchronization operations.UpdateOperationTest,RemotePendingChangesTestandAssuredReplicationPluginTest: 42/42 green on the current head - the three classes which cover the replay decision, the give-up budget and the assured ack this round changes.GenerationIdTest,InitOnLineTest,MonitorTest,StateMachineTestandChangelogBackendTestCasewere green on the previous head and are run by CI on every push. (ReplayFailuresTestwent withReplayFailureswhen the budget moved onto thePendingChange.)Left for later
DSRSShutdownSync.stopInstanceTimestampis latched by the firstReplicaOfflineMsga process ever sends, so the grace period which lets a collocated replication server forward it is already spent at the shutdown it is meant to cover. Any online import trips it, with or without this PR.disable()can drop a change a replay thread is mid-apply: the replay path takes noserviceStateLockanddisableService()joins only the listener, so a thread can be betweenmarkInProgress()andcommit()whenstate.save()persists a watermark excluding it. Pre-existing - master had noclear()there and replayed the change twice silently, where this PR logsERR_OPERATION_NOT_FOUND_IN_PENDING.op.run()whilenum-update-replay-threadsis reconfigured needs a blocking test plugin, which would cover Disabling a replication domain can drop a change a replay thread is applying #908 as well.ModifyDNconflict solved whileserver-error-result-codeis one of the codes conflict resolution owns has no test; the new test covers the other half, the change conflict resolution can not solve.num-update-replay-threadschange acks every in-flight assured SAFE_READ write with a replay error, for changes this replica applies moments later: either a replay thread finishes the change it owns before it exits, or the cost is written down in the javadoc.abandonReplay()logs nothing when the domain is disabled for an import or a restore, while the ack still carries the error - the peer records a replay error the local log never explains.changeConflictResolutionCanNotSolveOnTheServerErrorCodeIsRetrieddoes not pin conflict resolution being reached: puttingUNWILLING_TO_PERFORMback intoisServerFailure()gives the same observables. Neither does any test cover theisCommitted()half ofrecordReplayFailure()'s guard - a change committed behind an older uncommitted one - norsessionGenerationandreadFractionalConfig()underserviceStateLock.@VisibleForTestingsetter; they belong inReplicationDomainCfg, next toreplay-thread-number, so that an operator whose maintenance windows are longer than five minutes can say so.