/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2009 Sun Microsystems, Inc. * Portions Copyright 2011-2015 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.plugin; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.TreeMap; import java.util.concurrent.Callable; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.forgerock.util.annotations.VisibleForTesting; import org.opends.server.replication.common.CSN; import org.opends.server.replication.common.CSNGenerator; import org.opends.server.replication.protocol.LDAPUpdateMsg; import org.opends.server.replication.protocol.ReplicaOfflineMsg; import org.opends.server.replication.protocol.UpdateMsg; import org.opends.server.replication.service.ReplicationDomain; import org.opends.server.types.operation.PluginOperation; /** * This class is used to store the list of local operations currently * in progress and not yet committed in the database. *

* It is used to make sure that operations are sent to the Replication Server * in the order defined by their CSN. It is also used to update the ServerState * at the appropriate time. *

* An object of this class is instantiated for each ReplicationDomain. */ class PendingChanges { private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass(); /** * How many local changes a recovery keeps at most, and how many bytes their messages take at * most: past either, it keeps none, and searches for them as for the changes done before it began. */ static final int MAX_KEPT_CHANGES = 10000; static final long MAX_KEPT_BYTES = 32L * 1024 * 1024; /** A map used to store the pending changes. */ private final TreeMap pendingChanges = new TreeMap<>(); /** * The {@link CSNGenerator} to use to create new unique CSNs * for each operation done on the replication domain. */ private final CSNGenerator csnGenerator; /** The ReplicationDomain that will be used to send UpdateMsg. */ private final ReplicationDomain domain; /** Told that the replica of this domain announces itself offline, or takes that back. */ private final ReplicaOfflineAnnouncer replicaOfflineAnnouncer; /** * Changed under the lock the domain starts and ends a recovery under - and ended under the lock of * this list as well - and read without either by {@link #isRecovering()}. */ private volatile boolean recoveringOldChanges; /** What the recovery which runs keeps of the local changes: {@code null} until it first needs it. */ private Recovery recovery; /** How many local changes a recovery keeps at most: fewer in the tests which make it run over. */ private int maxKeptChanges = MAX_KEPT_CHANGES; /** * Creates a new PendingChanges using the provided CSNGenerator. * * @param csnGenerator The CSNGenerator to use to create new unique CSNs. * @param domain The ReplicationDomain that will be used to send UpdateMsg. * @param replicaOfflineAnnouncer Told that the replica of this domain announces itself * offline, before the message announcing it is published, and that it takes * the announcement back when the broker refused the message. */ PendingChanges(CSNGenerator csnGenerator, ReplicationDomain domain, ReplicaOfflineAnnouncer replicaOfflineAnnouncer) { this.csnGenerator = csnGenerator; this.domain = domain; this.replicaOfflineAnnouncer = replicaOfflineAnnouncer; } /** * Remove and return an update form the pending changes list. * * @param csn * The CSN of the update to remove. */ synchronized void remove(CSN csn) { pendingChanges.remove(csn); } /** * Returns the number of update currently in the list. * * @return The number of update currently in the list. */ int size() { return pendingChanges.size(); } /** * Mark an update message as committed. * * @param csn The CSN of the update message that must be set as committed. * @param msg The message associated to the update. */ private synchronized void commit(CSN csn, LDAPUpdateMsg msg) { final PendingChange curChange = pendingChanges.get(csn); if (curChange == null) { throw new NoSuchElementException(); } curChange.setCommitted(true); curChange.setMsg(msg); } /** * Add a new UpdateMsg to the pending list from the provided local operation. * * @param operation The local operation for which an UpdateMsg must * be added in the pending list. * @return The CSN now associated to the operation. */ synchronized CSN putLocalOperation(PluginOperation operation) { final CSN csn = csnGenerator.newCSN(); if (!operation.isSynchronizationOperation()) { pendingChanges.put(csn, new PendingChange(csn, operation, null)); } return csn; } /** * Add a replica offline message to the pending list and publish it, if the changes which * come before it have all been published. *

* The message carries the newest CSN of the replica, so a change which is still in flight * holds it back - and there is nobody left to publish it afterwards: the caller announces * the replica offline while its service is being disabled, and the broker stops right after. * Such a message is given up on rather than left queued, so that it is neither reported as * sent nor published later on the session which follows. * * @return the CSN of the message which was published, or {@code null} if it could not be: * a change which is still in flight holds it back, or the broker refused it */ public synchronized CSN putReplicaOfflineMsg() { final CSN offlineCSN = csnGenerator.newCSN(); final PendingChange pendingChange = new PendingChange(offlineCSN, null, new ReplicaOfflineMsg(offlineCSN)); pendingChange.setCommitted(true); pendingChanges.put(offlineCSN, pendingChange); /* * The message is the last change of the queue, so a push which did not reach it, or which * the broker refused, reports another CSN or none. */ final boolean published = offlineCSN.equals(pushCommittedChanges()); // pushCommittedChanges() removes whatever it reached, so the message is still listed here // if and only if a change before it held it back - it is dropped rather than left queued. pendingChanges.remove(offlineCSN); return published ? offlineCSN : null; } /** * Push all committed local changes to the replicationServer service. * * @return the CSN of the last {@link ReplicaOfflineMsg} the replication service accepted, or * {@code null} if none was pushed or the broker refused it. The announcement that a * replica goes offline is the one message whose delivery the caller must know about: * it is stored nowhere, so nothing publishes it again, while a change the broker * refuses is republished from the historical information of its entry on the next * session. */ synchronized CSN pushCommittedChanges() { CSN publishedOfflineCSN = null; // peek the oldest change Entry firstEntry = pendingChanges.firstEntry(); if (firstEntry == null) { return null; } PendingChange firstChange = firstEntry.getValue(); while (firstChange != null && firstChange.isCommitted()) { final PluginOperation op = firstChange.getOp(); final UpdateMsg msg = firstChange.getMsg(); if (msg instanceof LDAPUpdateMsg && op != null && !op.isSynchronizationOperation()) { if (!recoveringOldChanges) { domain.publish(msg); } else { // do not push updates until the RS catches up: the recovery publishes them. // Kept before the state takes it: the first change kept fixes where the search of the // recovery ends, at the last change pushed before it. // @see #setRecovering(boolean) recovery().keep((LDAPUpdateMsg) msg); domain.getServerState().update(msg.getCSN()); } } else if (msg instanceof ReplicaOfflineMsg && recoveringOldChanges) { /* * The announcement carries the newest CSN of this replica, past the changes the recovery * has still to publish: the replication server would take this replica for offline from * there - its changelog index past the changes which reach it afterwards. It is given up * on, as one a change in flight holds back is, whatever the broker would refuse. */ if (logger.isTraceEnabled()) { logger.trace("The announcement that the replica goes offline at " + msg.getCSN() + " is given up on: a recovery has changes before it to publish"); } } else if (msg instanceof ReplicaOfflineMsg) { /* * Announce the replica offline before the message reaches the wire, and not after: * a collocated replication server forwards it as soon as it has it, and a forward * which finds nothing announced leaves the shutdown waiting out the whole grace * period of a message the topology already has. */ final CSN offlineCSN = msg.getCSN(); replicaOfflineAnnouncer.announce(offlineCSN); if (domain.publish(msg)) { publishedOfflineCSN = offlineCSN; } else { // The broker wrote it to no session, so nobody will forward what was announced. replicaOfflineAnnouncer.withdraw(offlineCSN); } } // false warning: firstEntry will not be null if firstChange is not null pendingChanges.remove(firstEntry.getKey()); // peek the oldest change firstEntry = pendingChanges.firstEntry(); firstChange = firstEntry != null ? firstEntry.getValue() : null; } return publishedOfflineCSN; } /** * Mark an update message as committed, then * push all committed local changes to the replicationServer service * in a single atomic operation. * * @param csn The CSN of the update message that must be set as committed. * @param msg The message associated to the update. */ synchronized void commitAndPushCommittedChanges(CSN csn, LDAPUpdateMsg msg) { commit(csn, msg); pushCommittedChanges(); } /** * Set the PendingChangesList structure in a mode where it is waiting for the * RS to receive all the previous changes to be sent before starting to * process the changes normally. In this mode, The Domain does not publish the * changes from the pendingChanges because there are older changes that need * to be published before. * *

* The local changes pushed meanwhile are kept for the recovery, which publishes them once it has * published the ones done before it began. A recovery which is already running goes on, with what * it kept: a session which starts while it runs starts no other. *

* It is set without the lock of this list, which the start of a session must not take, but under * the one the domain ends a recovery under as well: a recovery the session needs is not ended by * one which was about to end. * * @param recovering * The recovering status that must be set. */ public void setRecovering(boolean recovering) { recoveringOldChanges = recovering; } /** * Tells whether the local changes are left to a recovery, which publishes them. *

* It is read without the lock of this list, which the start of a session must not take: the * session only learns from it that it needs a missing change publisher to end the recovery, * and that one ends it under the lock. * * @return {@code true} if the local changes are left to a recovery */ boolean isRecovering() { return recoveringOldChanges; } /** * Tells whether the recovery which runs keeps the local changes, and where its search of the * historical information ends then. * * @return the recovery which keeps the local changes, or {@code null} if its search has to go up * to the last local change: no recovery runs, or it kept too many changes to keep any */ synchronized KeptChanges keptChanges() { if (!recoveringOldChanges) { return null; } final Recovery running = recovery(); return running.kept != null ? new KeptChanges(running.searchUpTo) : null; } /** * Returns the local changes the recovery which runs kept which come after the provided CSN, in * order. * * @param csn * where the recovery has got to * @return the changes, or {@code null} if no recovery runs, or it kept too many changes to keep any */ synchronized List keptChangesAfter(CSN csn) { final KeptChanges keeping = keptChanges(); return keeping != null ? new ArrayList<>(recovery.kept.tailMap(csn, false).values()) : null; } /** * Returns the local changes the recovery which runs kept which come after the provided CSN, in * order, whatever it searches for: the data the domain read its state from again may not reflect * them. * * @param newest * the newest change of this replica the data holds, or {@code null} if it holds none * @return the changes, none if no recovery keeps any */ synchronized List keptChangesNewerThan(CSN newest) { if (recovery == null || recovery.kept == null) { return new ArrayList<>(); } return new ArrayList<>((newest != null ? recovery.kept.tailMap(newest, false) : recovery.kept).values()); } /** * Allows to update the recovery situation by comparing the CSN of the last * change that was sent to the ReplicationServer with the CSN of the last * operation that was taken out of the PendingChanges list. If the two match * then the recovery is completed and normal procedure can restart. Otherwise * the RSUpdater thread must continue to look for older changes and no changes * can be committed from the pendingChanges list. *

* The recovery ends at once for every change pushed after it: a change which comes here once * this list publishes again must find the broker taking it as well, or it is refused, while * the domain state records it as done and no recovery is left to publish it. The recovery is * therefore ended here, under the lock {@link #pushCommittedChanges()} takes, before any change * can be pushed. What it kept is dropped with it. * * @param recovered * The CSN of the last change that was published to the * ReplicationServer. * @param recoveryEnd * Ends the recovery, unless a session which started meanwhile needs it, and has the * broker take the changes again. * @return A boolean indicating if the recovery is completed (false) or must * continue (true). */ synchronized boolean recoveryUntil(CSN recovered, RecoveryEnd recoveryEnd) { final CSN lastLocalChange = domain.getLastLocalChange(); if (recoveringOldChanges && recovered != null && recovered.isNewerThanOrEqualTo(lastLocalChange)) { recoveryEnd.end(() -> { recoveringOldChanges = false; recovery = null; }); } return recoveringOldChanges; } /** * Drops the provided local changes the recovery which runs kept: the data of the domain, read * again, does not hold them - it was restored or imported since they were done - and the replicas * would get changes this one does not hold. * * @param csns * the CSNs of the changes to drop */ synchronized void dropKeptChanges(Collection csns) { if (recovery == null || recovery.kept == null || csns.isEmpty()) { return; } recovery.kept.keySet().removeAll(csns); recovery.keptBytes = 0; for (LDAPUpdateMsg msg : recovery.kept.values()) { recovery.keptBytes += msg.size(); } } /** * Has the recovery which runs keep no more local changes, and drop the ones it kept: it searches * for them in the historical information instead, as one which kept too many does. */ synchronized void keepNoMoreChanges() { if (recovery != null) { recovery.kept = null; } } /** * Returns what the recovery which runs keeps of the local changes, under the lock of this list. * It is made as it is first needed - as the recovery keeps its first change, or as its publisher * asks where its search ends - so the search ends at the last change pushed before any was kept. */ private Recovery recovery() { if (recovery == null) { recovery = new Recovery(domain.getLastLocalChange()); } return recovery; } /** * Sets how many local changes a recovery which starts from now on keeps at most: with none, it * searches for all of them in the historical information. * * @param maxKeptChanges * how many local changes a recovery keeps at most */ @VisibleForTesting synchronized void setMaxKeptChanges(int maxKeptChanges) { this.maxKeptChanges = maxKeptChanges; } /** * What a recovery keeps of the local changes pushed while it runs. *

* A recovery republishes what the replication server misses from the historical information of * the entries, and its search of it races the commits: a change committed while it searches is in * the domain state, and not in what it found. The changes pushed once it began are kept instead, * in order and with their messages, so that its search only has to cover the ones pushed before: * up to the last of them, which no commit moves. A change kept is published as it was done, where * a later change of the same values leaves nothing of it in the historical information, and a * delete kept does not depend on the deletes the domain keeps aside, which are capped. *

* What it keeps stays until it ends: a missing change publisher whose broker refused a change, * or whose session was replaced by one whose replication server holds less, leaves them to the * one which goes on, which may start from further back. Past {@link #maxKeptChanges} changes or * {@link #MAX_KEPT_BYTES} bytes it keeps none, and the recovery searches for them from where it * has got to, holding the local changes back at the end, as it does when nothing is kept. */ private final class Recovery { /** The last local change pushed before this recovery kept any: its search ends there. */ private final CSN searchUpTo; /** The local changes pushed since, in order; {@code null} once there were too many. */ private TreeMap kept; private long keptBytes; private Recovery(CSN searchUpTo) { this.searchUpTo = searchUpTo; this.kept = maxKeptChanges > 0 ? new TreeMap() : null; } private void keep(LDAPUpdateMsg msg) { if (kept == null) { return; } /* * Published as a recovery publishes what it rebuilds: not assured. Its operation may still be * waiting for the acknowledgement when a round publishes it - nothing orders the two - and it * then gets none and times out, as it does when the round comes later: it waits as the domain * is configured to, on the CSN of the message, and not as the message says. */ if (msg.isAssured()) { msg.setNotAssured(); } kept.put(msg.getCSN(), msg); keptBytes += msg.size(); if (kept.size() > maxKeptChanges || keptBytes > MAX_KEPT_BYTES) { kept = null; if (logger.isDebugEnabled()) { logger.debug(LocalizableMessage.raw("the recovery keeps no more local changes after " + msg.getCSN() + ": it searches for them in the historical information")); } } } } /** A recovery which keeps the local changes pushed while it runs. */ static final class KeptChanges { /** * The last local change pushed before the recovery kept any: its search ends there, and the * changes it kept come next. {@code null} if the domain state held no change of this replica. */ final CSN searchUpTo; private KeptChanges(CSN searchUpTo) { this.searchUpTo = searchUpTo; } } /** Ends a recovery the pending changes have caught up with. */ interface RecoveryEnd { /** * Ends the recovery, unless a session which started meanwhile has given it changes of its own * to recover: runs {@code recovered} under the lock a recovery is started under, and has the * broker take the changes again. Called under the lock of the pending changes. * * @param recovered * lets the pending changes publish the local changes again */ void end(Runnable recovered); } /** * Runs the provided rounds of a recovery while no local change can be numbered or pushed. *

* A round publishes the changes done when it began, and a change done while it runs is left * to a round after it, so a recovery could chase the local changes for as long as they go on. * The rounds run here are the last ones: what they publish is all there is, and nothing can be * pushed between their end and the end of the recovery they decide. The local operations wait * for them - for their CSN and for their push - for as long as publishing the changes done while * the round before ran takes, where a publish in normal operation holds the same lock for one * change. * * @param * what the rounds return * @param round * the rounds of the recovery * @return what the round returned * @throws Exception * if the round failed */ synchronized T holdBackLocalChangesWhile(Callable round) throws Exception { return round.call(); } /** * Told that the replica of this domain announces itself offline, or takes that back. *

* A collocated replication server can forward a {@link ReplicaOfflineMsg} as soon as it is on * the wire, and its shutdown waits for that forward, so the announcement has to be in place * before the message is published: one made afterwards is one the forward found nothing to * clear, and the shutdown spends its whole grace period on a message which has already gone * out. The broker may still refuse the message once it is announced, and then the * announcement is withdrawn: what stays announced is what the broker reports as written. */ interface ReplicaOfflineAnnouncer { /** * Announces that the replica goes offline at the provided CSN. * * @param offlineCSN * the CSN of the ReplicaOfflineMsg which is about to be published */ void announce(CSN offlineCSN); /** * Withdraws the announcement of a message the broker refused: it was written to no session, * so nobody will forward it. * * @param offlineCSN * the CSN of the ReplicaOfflineMsg which was announced and not published */ void withdraw(CSN offlineCSN); } }