From b0970cc1a88be6f5dd28edcfdeb635575df575a3 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Sat, 19 Sep 2026 07:54:37 +0000
Subject: [PATCH] [#991] Decide and report an index configuration change outside the write which is replayed (#997)

---
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java                 |  150 +++++++---
 opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java |  502 +++++++++++++++++++++++++++++++++++++-
 opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java           |  120 ++++++--
 3 files changed, 674 insertions(+), 98 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java
index a63026c..86ddbea 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/AttributeIndex.java
@@ -14,6 +14,7 @@
  * Copyright 2006-2010 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
  * Portions Copyright 2014 Manuel Gaupp
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.backends.pluggable;
 
@@ -924,18 +925,53 @@
       // indexIdToIndexes
       newIndexIdToIndexes.putAll(updatedIndexes);
 
+      // What the new configuration asks of the indexes which stay is decided here, before any of
+      // the three writes below, and reported here as well. Decided before the write which applies
+      // it: neither the entry limit an index holds nor its in-memory trusted flag is rolled back
+      // with the transaction, while the removal of the persisted TRUSTED flag is, so an attempt
+      // which rolls back would leave the raised limit in place, and a replay of it would compare
+      // that limit against itself, find nothing to rebuild, and commit an index whose entry limit
+      // was raised and which the storage still records as trusted. Reported before the writes
+      // rather than once they have committed, because the instruction holds whichever way they go:
+      // the configuration entry already holds the raised limit when this listener runs, and the
+      // next open of the index applies it to a tree whose keys were given up under the lower one.
+      // Only the limit itself waits for the write which untrusts the index to commit.
+      final List<Index> indexesToUntrust = new ArrayList<>();
+      final List<LocalizableMessage> rebuildMessages = new ArrayList<>();
+      planIndexUpdates(updatedIndexes.values(), newConfiguration, indexesToUntrust, rebuildMessages);
+      for (LocalizableMessage rebuildMessage : rebuildMessages)
+      {
+        ccr.setAdminActionRequired(true);
+        ccr.addMessage(rebuildMessage);
+      }
+
       // Open added indexes *before* adding them to indexIdToIndexes
+      final List<TreeName> addedIndexesToRebuild = new ArrayList<>();
       entryContainer.getRootContainer().getStorage().write(new WriteOperation()
       {
         @Override
         public void run(WriteableTransaction txn) throws Exception
         {
+          // Emptied at the start of every attempt: the storage may replay this operation, and what
+          // has to be reported is what the attempt which commits found, not what every attempt did.
+          addedIndexesToRebuild.clear();
           for (MatchingRuleIndex addedIndex : addedIndexes.values())
           {
-            createIndex(txn, addedIndex, ccr);
+            if (createIndex(txn, addedIndex))
+            {
+              addedIndexesToRebuild.add(addedIndex.getName());
+            }
           }
         }
       });
+      // Reported once that write has committed, since a message an attempt which rolls back added
+      // to the result stays there, and the operator would be told once per attempt.
+      // EntryContainer.applyConfigurationAdd reports the index it adds the same way.
+      for (TreeName addedIndex : addedIndexesToRebuild)
+      {
+        ccr.setAdminActionRequired(true);
+        ccr.addMessage(NOTE_INDEX_ADD_REQUIRES_REBUILD.get(addedIndex));
+      }
 
       config = newConfiguration;
       indexingOptions = newIndexingOptions;
@@ -962,17 +998,28 @@
         entryContainer.unlock();
       }
 
-      entryContainer.getRootContainer().getStorage().write(new WriteOperation()
+      // The only part of what the indexes which stay are asked for that is written down. A change
+      // which untrusts none of them - a lowered limit - opens no transaction, rather than one a
+      // bounded storage could give up on with nothing to give up; VLVIndex guards its write the
+      // same way.
+      if (!indexesToUntrust.isEmpty())
       {
-        @Override
-        public void run(WriteableTransaction txn) throws Exception
+        entryContainer.getRootContainer().getStorage().write(new WriteOperation()
         {
-          for (final Index updatedIndex : updatedIndexes.values())
+          @Override
+          public void run(WriteableTransaction txn) throws Exception
           {
-            updateIndex(updatedIndex, newConfiguration, ccr, txn);
+            for (final Index updatedIndex : indexesToUntrust)
+            {
+              updatedIndex.setTrusted(txn, false);
+            }
           }
-        }
-      });
+        });
+      }
+      for (final Index updatedIndex : updatedIndexes.values())
+      {
+        updatedIndex.setIndexEntryLimit(newConfiguration.getIndexEntryLimit());
+      }
     }
     catch (Exception e)
     {
@@ -983,36 +1030,45 @@
     return ccr;
   }
 
-  private static void createIndex(WriteableTransaction txn, MatchingRuleIndex index, ConfigChangeResult ccr)
+  /**
+   * Opens an index this change adds, and answers whether it has to be rebuilt before it is used.
+   * Answered to the caller rather than reported from here: this runs inside a {@link WriteOperation}
+   * the storage may replay, and the report belongs to the attempt which commits.
+   */
+  private static boolean createIndex(WriteableTransaction txn, MatchingRuleIndex index)
   {
     index.open(txn, true);
-    if (!index.isTrusted())
-    {
-      ccr.setAdminActionRequired(true);
-      ccr.addMessage(NOTE_INDEX_ADD_REQUIRES_REBUILD.get(index.getName()));
-    }
+    return !index.isTrusted();
   }
 
-  private static void updateIndex(Index updatedIndex, BackendIndexCfg newConfig, ConfigChangeResult ccr,
-      WriteableTransaction txn)
+  /**
+   * Works out what the new configuration asks of the indexes which stay: which of them may no longer
+   * be trusted, and what the operator has to be told about each of them. Decided from the state the
+   * indexes are in before anything is applied to them, so that a write the storage replays reaches
+   * the same answer on every attempt.
+   */
+  private static void planIndexUpdates(Collection<MatchingRuleIndex> updatedIndexes, BackendIndexCfg newConfig,
+      List<Index> indexesToUntrust, List<LocalizableMessage> rebuildMessages)
   {
-    // This index could still be used since a new smaller index size limit doesn't impact validity of the results.
-    boolean newLimitRequiresRebuild = updatedIndex.setIndexEntryLimit(newConfig.getIndexEntryLimit());
-    if (newLimitRequiresRebuild)
+    for (Index updatedIndex : updatedIndexes)
     {
-      ccr.setAdminActionRequired(true);
-      ccr.addMessage(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.get(updatedIndex.getName()));
-    }
-    // This index could still be used when disabling confidentiality.
-    boolean newConfidentialityRequiresRebuild = updatedIndex.setConfidential(newConfig.isConfidentialityEnabled());
-    if (newConfidentialityRequiresRebuild)
-    {
-      ccr.setAdminActionRequired(true);
-      ccr.addMessage(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.get(updatedIndex.getName()));
-    }
-    if (newLimitRequiresRebuild || newConfidentialityRequiresRebuild)
-    {
-      updatedIndex.setTrusted(txn, false);
+      // This index could still be used since a new smaller index size limit doesn't impact validity of the results.
+      boolean newLimitRequiresRebuild = updatedIndex.getIndexEntryLimit() < newConfig.getIndexEntryLimit();
+      if (newLimitRequiresRebuild)
+      {
+        rebuildMessages.add(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.get(updatedIndex.getName()));
+      }
+      // This index could still be used when disabling confidentiality. Asked rather than told: for an
+      // index this only compares the configuration with the parameters its crypto suite holds.
+      boolean newConfidentialityRequiresRebuild = updatedIndex.setConfidential(newConfig.isConfidentialityEnabled());
+      if (newConfidentialityRequiresRebuild)
+      {
+        rebuildMessages.add(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.get(updatedIndex.getName()));
+      }
+      if (newLimitRequiresRebuild || newConfidentialityRequiresRebuild)
+      {
+        indexesToUntrust.add(updatedIndex);
+      }
     }
   }
 
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
index ca84641..6288ddc 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/VLVIndex.java
@@ -65,7 +65,6 @@
 import org.opends.server.controls.ServerSideSortRequestControl;
 import org.opends.server.controls.VLVRequestControl;
 import org.opends.server.controls.VLVResponseControl;
-import org.opends.server.core.DirectoryServer;
 import org.opends.server.core.SearchOperation;
 import org.opends.server.protocols.ldap.LDAPResultCode;
 import org.opends.server.types.Attribute;
@@ -73,7 +72,6 @@
 import org.opends.server.types.Entry;
 import org.opends.server.types.Modification;
 import org.opends.server.types.SearchFilter;
-import org.opends.server.util.StaticUtils;
 
 /**
  * This class represents a VLV index.
@@ -229,71 +227,103 @@
   @Override
   public synchronized ConfigChangeResult applyConfigurationChange(final BackendVLVIndexCfg cfg)
   {
-    try
+    final ConfigChangeResult ccr = new ConfigChangeResult();
+    /*
+     * What this change asks for is worked out here, before the write which applies it, and what it
+     * changes is published after that write has committed. Asked and answered from within a
+     * WriteOperation the storage may replay, every question below is asked of the configuration
+     * this vlvIndex holds and answered into the result of the change, and neither is rolled back
+     * with the transaction: an attempt which rolls back leaves this vlvIndex already holding the
+     * new definition, so the replay of it finds nothing changed, and it leaves the result already
+     * asking for the rebuild, which is the only thing that keeps the replay removing the TRUSTED
+     * flag the rollback put back. The operator is told to rebuild the index once per attempt, and
+     * what stops the storage from committing a vlvIndex it still records as trusted - answering a
+     * sorted search after a restart out of a tree built for the definition it no longer has - is
+     * that repetition. See OpenDJ issue #991, which reports this of AttributeIndex, where the
+     * index itself holds the answer and the replay does commit a stale index as trusted.
+     */
+    final boolean baseDNChanged = !config.getBaseDN().equals(cfg.getBaseDN());
+    if (baseDNChanged)
     {
-      final ConfigChangeResult ccr = new ConfigChangeResult();
-      storage.write(new WriteOperation()
-      {
-        @Override
-        public void run(final WriteableTransaction txn) throws Exception
-        {
-          applyConfigurationChange0(txn, cfg, ccr);
-        }
-      });
-      return ccr;
-    }
-    catch (final Exception e)
-    {
-      throw new StorageRuntimeException(e);
-    }
-  }
-
-  private synchronized void applyConfigurationChange0(
-      final WriteableTransaction txn, final BackendVLVIndexCfg cfg, final ConfigChangeResult ccr)
-  {
-    // Update base DN only if changed
-    if (!config.getBaseDN().equals(cfg.getBaseDN()))
-    {
-      this.baseDN = cfg.getBaseDN();
       ccr.setAdminActionRequired(true);
     }
-
-    // Update scope only if changed
-    if (!config.getScope().equals(cfg.getScope()))
+    final boolean scopeChanged = !config.getScope().equals(cfg.getScope());
+    if (scopeChanged)
     {
-      this.scope = convertScope(cfg.getScope());
       ccr.setAdminActionRequired(true);
     }
-
-    // Update the filter only if changed
-    if (!config.getFilter().equals(cfg.getFilter()))
+    // parseSearchFilter() asks for the administrative action itself, and only once it has parsed.
+    final boolean filterChanged = !config.getFilter().equals(cfg.getFilter());
+    final SearchFilter newFilter = filterChanged ? parseSearchFilter(cfg, getName().toString(), ccr) : filter;
+    final boolean sortOrderChanged = !config.getSortOrder().equals(cfg.getSortOrder());
+    final List<SortKey> newSortKeys;
+    if (sortOrderChanged)
     {
-      this.filter = parseSearchFilter(cfg, getName().toString(), ccr);
-    }
-
-    // Update the sort order only if changed
-    if (!config.getSortOrder().equals(cfg.getSortOrder()))
-    {
-      this.sortKeys = parseSortKeys(cfg.getSortOrder(), ccr);
+      newSortKeys = parseSortKeys(cfg.getSortOrder(), ccr);
       ccr.setAdminActionRequired(true);
     }
-
-    if (ccr.adminActionRequired())
+    else
     {
-      trusted = false;
+      newSortKeys = sortKeys;
+    }
+
+    final boolean requiresRebuild = ccr.adminActionRequired();
+    if (requiresRebuild)
+    {
+      // Reported outside the write rather than from within it, since a message an attempt which
+      // rolls back added to the result stays there and the operator would be told once per
+      // attempt; and before the write rather than once it has committed, because the instruction
+      // holds whichever way the write goes: the configuration entry already holds the new
+      // definition when this listener runs, and the next open of this vlvIndex applies it to a
+      // tree built for the definition it no longer has.
       ccr.addMessage(NOTE_INDEX_ADD_REQUIRES_REBUILD.get(getName()));
+
+      // The only part of this change which is written down. A change asking for nothing this
+      // vlvIndex has to be rebuilt for opens no transaction, rather than one a bounded storage
+      // could give up on with nothing to give up. A conflict raised by the flag removal is left to
+      // the storage, whose retry loop replays the operation: caught inside the operation, as it
+      // used to be, it was swallowed where the storage was waiting to be told to replay, and the
+      // attempt committed having done nothing. What the storage gives up on is reported the way
+      // AttributeIndex reports it, with the result built so far - the rebuild asked for above
+      // holds on that road too - rather than thrown past ConfigurationHandler, which catches
+      // nothing a listener throws and would discard that result whole.
       try
       {
-        state.removeFlagsFromIndex(txn, getName(), IndexFlag.TRUSTED);
+        storage.write(new WriteOperation()
+        {
+          @Override
+          public void run(final WriteableTransaction txn) throws Exception
+          {
+            setTrusted(txn, false);
+          }
+        });
       }
-      catch (final StorageRuntimeException de)
+      catch (final Exception e)
       {
-        ccr.addMessage(LocalizableMessage.raw(StaticUtils.stackTraceToSingleLineString(de)));
-        ccr.setResultCodeIfSuccess(DirectoryServer.getCoreConfigManager().getServerErrorResultCode());
+        ccr.setResultCode(getCoreConfigManager().getServerErrorResultCode());
+        ccr.addMessage(LocalizableMessage.raw(stackTraceToSingleLineString(e)));
+        return ccr;
       }
     }
 
+    if (baseDNChanged)
+    {
+      this.baseDN = cfg.getBaseDN();
+    }
+    if (scopeChanged)
+    {
+      this.scope = convertScope(cfg.getScope());
+    }
+    if (filterChanged)
+    {
+      this.filter = newFilter;
+    }
+    if (sortOrderChanged)
+    {
+      this.sortKeys = newSortKeys;
+    }
     this.config = cfg;
+    return ccr;
   }
 
   private List<SortKey> parseSortKeys(final String sortOrder, ConfigChangeResult ccr)
@@ -354,6 +384,30 @@
     return trusted;
   }
 
+  /** The sort keys this vlvIndex encodes its keys with: the definition the last committed change published. */
+  List<SortKey> getSortKeys()
+  {
+    return sortKeys;
+  }
+
+  /** The base DN this vlvIndex searches under: the definition the last committed change published. */
+  DN getBaseDN()
+  {
+    return baseDN;
+  }
+
+  /** The scope this vlvIndex searches with: the definition the last committed change published. */
+  SearchScope getScope()
+  {
+    return scope;
+  }
+
+  /** The filter this vlvIndex searches with: the definition the last committed change published. */
+  SearchFilter getFilter()
+  {
+    return filter;
+  }
+
   synchronized void setTrusted(final WriteableTransaction txn, final boolean trusted) throws StorageRuntimeException
   {
     this.trusted = trusted;
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java
index 19c1aaa..5c4897a 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/ReplayedConfigChangeTest.java
@@ -19,6 +19,8 @@
 import static org.opends.messages.BackendMessages.ERR_BACKEND_BASEDN_NO_LONGER_HELD;
 import static org.opends.messages.BackendMessages.ERR_BACKEND_CANNOT_LIST_TREES_AFTER_BASEDN_CHANGE;
 import static org.opends.messages.BackendMessages.ERR_BACKEND_CANNOT_REGISTER_BASEDN;
+import static org.opends.messages.BackendMessages.NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD;
+import static org.opends.messages.BackendMessages.NOTE_INDEX_ADD_REQUIRES_REBUILD;
 import static org.forgerock.opendj.config.ConfigurationMock.mockCfg;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.mock;
@@ -44,13 +46,18 @@
 import org.forgerock.opendj.ldap.ByteString;
 import org.forgerock.opendj.ldap.DN;
 import org.forgerock.opendj.ldap.ResultCode;
+import org.forgerock.opendj.ldap.SearchScope;
+import org.forgerock.opendj.ldap.SortKey;
 import org.forgerock.opendj.ldap.schema.AttributeType;
 import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType;
+import org.forgerock.opendj.server.config.meta.BackendVLVIndexCfgDefn.Scope;
 import org.forgerock.opendj.server.config.server.BackendIndexCfg;
+import org.forgerock.opendj.server.config.server.BackendVLVIndexCfg;
 import org.forgerock.opendj.server.config.server.PDBBackendCfg;
 import org.opends.server.DirectoryServerTestCase;
 import org.opends.server.TestCaseUtils;
 import org.opends.server.backends.pdb.PDBStorage;
+import org.opends.server.backends.pluggable.AttributeIndex.MatchingRuleIndex;
 import org.opends.server.backends.pluggable.State.IndexFlag;
 import org.opends.server.backends.pluggable.spi.AccessMode;
 import org.opends.server.backends.pluggable.spi.Cursor;
@@ -63,6 +70,8 @@
 import org.opends.server.backends.pluggable.spi.UpdateFunction;
 import org.opends.server.backends.pluggable.spi.WriteOperation;
 import org.opends.server.backends.pluggable.spi.WriteableTransaction;
+import org.opends.server.core.AddOperation;
+import org.opends.server.core.DirectoryServer;
 import org.opends.server.core.ServerContext;
 import org.opends.server.types.BackupConfig;
 import org.opends.server.types.BackupDirectory;
@@ -95,6 +104,8 @@
   private static final DN ADDED = DN.valueOf("dc=b907c,dc=com");
   /** Hierarchically related to {@link #KEPT}, which one backend is not allowed to serve as well. */
   private static final DN UNREGISTRABLE = DN.valueOf("dc=b907d,dc=b907a,dc=com");
+  /** Held in lower case, which is how an entry container keys the vlvIndexes it holds. */
+  private static final String VLV_INDEX_NAME = "b907vlv";
 
   private ServerContext serverContext;
   private AttributeType cnType;
@@ -545,6 +556,16 @@
     }
   }
 
+  private static Set<TreeName> treesOf(EntryContainer ec)
+  {
+    final Set<TreeName> names = new HashSet<>();
+    for (Tree tree : ec.listTrees())
+    {
+      names.add(tree.getName());
+    }
+    return names;
+  }
+
   /** The messages a change result carries, by identity rather than by their formatted text. */
   private static Set<Integer> ordinalsOf(ConfigChangeResult ccr)
   {
@@ -556,14 +577,344 @@
     return ordinals;
   }
 
-  private static Set<TreeName> treesOf(EntryContainer ec)
+  /** The result code a configuration listener answers a failure it caught with. */
+  private static ResultCode serverErrorResultCode()
   {
-    final Set<TreeName> names = new HashSet<>();
-    for (Tree tree : ec.listTrees())
+    return DirectoryServer.getCoreConfigManager().getServerErrorResultCode();
+  }
+
+  /**
+   * An index a change adds is opened untrusted while the backend holds entries, and the operator is
+   * told to rebuild it. That message belongs to the attempt which commits: added to the result from
+   * inside the operation, a replay repeats it once per attempt.
+   */
+  @Test
+  public void anIndexAddedByAChangeIsReportedOnceWhenTheTransactionIsReplayed() throws Exception
+  {
+    final ReplayingBackend backend = openBackend(newTreeSet(KEPT));
+    try
     {
-      names.add(tree.getName());
+      addBaseEntry(backend, KEPT, "b907a");
+      final AttributeIndex index = backend.getRootContainer().getEntryContainer(KEPT).getAttributeIndex(cnType);
+
+      // The first of the three writes a change makes is the one which opens the indexes it adds.
+      backend.storage.conflictAtCommit(1);
+      final ConfigChangeResult ccr =
+          index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY, IndexType.PRESENCE), 4000));
+
+      assertThat(backend.storage.attempts()).isEqualTo(2);
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).isTrue();
+      assertThat(ccr.getMessages()).as("the rebuild the added index needs, asked for once").hasSize(1);
+      assertThat(ordinalsOf(ccr)).containsOnly(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal());
+      assertThat(index.isIndexed(IndexType.PRESENCE)).as("the index type the change added").isTrue();
     }
-    return names;
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * Raising the entry limit of an index leaves it holding the keys it gave up under the lower limit,
+   * so it has to be rebuilt and may not be trusted until it is. The limit an index holds is not
+   * rolled back with the transaction while the removal of its persisted TRUSTED flag is, so an
+   * attempt which rolls back must not be what decides that the limit went up.
+   */
+  @Test
+  public void aRaisedEntryLimitUntrustsTheIndexWhenTheTransactionIsReplayed() throws Exception
+  {
+    final ReplayingBackend backend = openBackend(newTreeSet(KEPT));
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+
+      // The third write is the one which removes the flag: the first two open the indexes the
+      // change adds and delete the ones it removes, and it neither adds nor removes any.
+      final int writesBefore = backend.storage.writes();
+      backend.storage.conflictAtCommitOnWrite(3, 1);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY), 8000));
+
+      assertThat(backend.storage.writes()).as("the armed write was the last of three").isEqualTo(writesBefore + 3);
+      assertThat(backend.storage.attempts()).isEqualTo(2);
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).isTrue();
+      assertThat(ccr.getMessages()).as("the rebuild the raised limit needs, asked for once").hasSize(1);
+      assertThat(ordinalsOf(ccr)).containsOnly(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.ordinal());
+      assertThat(cnIndex.getIndexEntryLimit()).as("the limit the change applied").isEqualTo(8000);
+      assertThat(cnIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("the flag the attempt which committed had to remove").doesNotContain(TRUSTED);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * A vlvIndex whose sort order changes holds a tree sorted under the order it no longer has, so it
+   * may not be trusted until it is rebuilt. Every question it asks is asked of the configuration it
+   * holds, and no rollback takes that configuration back, so the replay of an attempt which rolled
+   * back finds nothing changed: the rebuild it goes on asking for is the administrative action the
+   * rolled back attempt left in the result, told to the operator once per attempt.
+   */
+  @Test
+  public void aChangedSortOrderUntrustsTheVlvIndexWhenTheTransactionIsReplayed() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithVlvIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final VLVIndex vlvIndex = ec.getVLVIndex(VLV_INDEX_NAME);
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName())).contains(TRUSTED);
+
+      backend.storage.conflictAtCommit(1);
+      final ConfigChangeResult ccr = vlvIndex.applyConfigurationChange(vlvIndexCfg("+sn"));
+
+      assertThat(backend.storage.attempts()).isEqualTo(2);
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).isTrue();
+      assertThat(ccr.getMessages()).as("the rebuild the new sort order needs, asked for once").hasSize(1);
+      assertThat(ordinalsOf(ccr)).containsOnly(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal());
+      assertThat(vlvIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName()))
+          .as("the flag the attempt which committed had to remove").doesNotContain(TRUSTED);
+      assertThat(vlvIndex.getSortKeys()).as("the definition the committed write published")
+          .containsExactly(new SortKey("sn", false));
+
+      // The definition the change applied is the one this vlvIndex holds from now on, so asking for
+      // it a second time asks for nothing, and opens no transaction to commit nothing.
+      final int writesBefore = backend.storage.writes();
+      final ConfigChangeResult again = vlvIndex.applyConfigurationChange(vlvIndexCfg("+sn"));
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(again.adminActionRequired()).as("a change which changes nothing").isFalse();
+      assertThat(again.getMessages()).isEmpty();
+      assertThat(backend.storage.writes()).as("a change which changes nothing opens no transaction")
+          .isEqualTo(writesBefore);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * A conflict the flag removal itself raises is reported to the operation by the transaction, as
+   * a {@link StorageRuntimeException} wrapping it, and belongs to the storage's retry loop. Caught
+   * inside the operation and turned into a message, it was a conflict swallowed where the storage
+   * was waiting to be told to replay: the attempt committed having removed nothing, and the change
+   * reported a failure against a vlvIndex the storage still recorded as trusted.
+   */
+  @Test
+  public void aConflictRaisedByTheRemovalOfTheVlvIndexFlagIsReplayed() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithVlvIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final VLVIndex vlvIndex = ec.getVLVIndex(VLV_INDEX_NAME);
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName())).contains(TRUSTED);
+
+      backend.storage.conflictAsTheTransactionReportsIt(1);
+      final ConfigChangeResult ccr = vlvIndex.applyConfigurationChange(vlvIndexCfg("+sn"));
+
+      assertThat(backend.storage.attempts()).as("replayed by the storage, not answered from inside").isEqualTo(2);
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).isTrue();
+      assertThat(ccr.getMessages()).as("the rebuild the new sort order needs, asked for once").hasSize(1);
+      assertThat(ordinalsOf(ccr)).containsOnly(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal());
+      assertThat(vlvIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName()))
+          .as("the flag the replayed attempt removed").doesNotContain(TRUSTED);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * A raised entry limit has to be rebuilt for whether or not the write which untrusts the index is
+   * applied: the configuration entry holds the raised limit before the listener runs, and the next
+   * open of the index applies it to a tree whose keys were given up under the lower one. So the
+   * instruction is given before that write, and a write the storage gives up on - the last attempt
+   * of its retry loop, or a failure it does not replay at all - leaves it in the result, next to
+   * the failure, rather than dropping it together with the limit it could not apply.
+   */
+  @Test
+  public void aRaisedEntryLimitIsReportedWhenTheWriteWhichUntrustsTheIndexGivesUp() throws Exception
+  {
+    final ReplayingBackend backend = openBackend(newTreeSet(KEPT));
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+
+      backend.storage.failWithoutReplayOnWrite(3);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY), 8000));
+
+      assertThat(ccr.getResultCode()).isEqualTo(serverErrorResultCode());
+      assertThat(ccr.adminActionRequired()).as("the rebuild the raised limit needs, on the road which failed").isTrue();
+      assertThat(ordinalsOf(ccr)).contains(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.ordinal());
+      assertThat(ccr.getMessages().toString()).as("the failure, next to the rebuild")
+          .contains(UnreplayableFailure.class.getSimpleName());
+      assertThat(cnIndex.getIndexEntryLimit()).as("the limit the failed write did not apply").isEqualTo(4000);
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("what the restart will read").contains(TRUSTED);
+
+      // The limit the failed write did not apply is still a change when asked for again: the
+      // configuration this attribute index holds was replaced before the write which gave up, and
+      // what the new one asks of the index is asked of the index, not of that configuration.
+      final ConfigChangeResult again = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY), 8000));
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(again.adminActionRequired()).as("the limit the failed write did not apply is still a change").isTrue();
+      assertThat(ordinalsOf(again)).containsOnly(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.ordinal());
+      assertThat(cnIndex.getIndexEntryLimit()).as("the limit the change applied").isEqualTo(8000);
+      assertThat(cnIndex.isTrusted()).isFalse();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).doesNotContain(TRUSTED);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * The same instruction survives a give-up on the first of the three writes, the one which opens
+   * the indexes the change adds - opened here with none to add: it is asked for before any of the
+   * writes, not only before the one which untrusts the index, since the configuration entry holds
+   * the raised limit whichever of them fails.
+   */
+  @Test
+  public void aRaisedEntryLimitIsReportedWhenTheWriteWhichAddsIndexesGivesUp() throws Exception
+  {
+    final ReplayingBackend backend = openBackend(newTreeSet(KEPT));
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final AttributeIndex index = ec.getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName())).contains(TRUSTED);
+
+      final int writesBefore = backend.storage.writes();
+      backend.storage.failWithoutReplayOnWrite(1);
+      final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY), 8000));
+
+      assertThat(backend.storage.writes()).as("the armed write was the first of three, and the last one made")
+          .isEqualTo(writesBefore + 1);
+      assertThat(ccr.getResultCode()).isEqualTo(serverErrorResultCode());
+      assertThat(ccr.adminActionRequired()).as("the rebuild the raised limit needs, asked for before the first write")
+          .isTrue();
+      assertThat(ordinalsOf(ccr)).contains(NOTE_CONFIG_INDEX_ENTRY_LIMIT_REQUIRES_REBUILD.ordinal());
+      assertThat(ccr.getMessages().toString()).as("the failure, next to the rebuild")
+          .contains(UnreplayableFailure.class.getSimpleName());
+      assertThat(cnIndex.getIndexEntryLimit()).as("the limit the failed change did not apply").isEqualTo(4000);
+      assertThat(persistedFlags(rootContainer, ec, cnIndex.getName()))
+          .as("what the restart will read").contains(TRUSTED);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * A lowered entry limit leaves every key the index holds valid, so it asks for no rebuild, and it
+   * untrusts nothing, so it opens no transaction: nothing for a bounded storage to give up on.
+   */
+  @Test
+  public void aLoweredEntryLimitNeedsNoRebuildAndOpensNoTransaction() throws Exception
+  {
+    final ReplayingBackend backend = openBackend(newTreeSet(KEPT));
+    try
+    {
+      final AttributeIndex index = backend.getRootContainer().getEntryContainer(KEPT).getAttributeIndex(cnType);
+      final MatchingRuleIndex cnIndex = index.getNameToIndexes().values().iterator().next();
+
+      final int writesBefore = backend.storage.writes();
+      final ConfigChangeResult ccr = index.applyConfigurationChange(indexCfg(newTreeSet(IndexType.EQUALITY), 2000));
+
+      assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(ccr.adminActionRequired()).as("a lowered limit needs no rebuild").isFalse();
+      assertThat(ccr.getMessages()).isEmpty();
+      assertThat(cnIndex.isTrusted()).isTrue();
+      assertThat(cnIndex.getIndexEntryLimit()).as("the limit the change applied").isEqualTo(2000);
+      assertThat(backend.storage.writes()).as("the two writes which add and remove indexes, and no third")
+          .isEqualTo(writesBefore + 2);
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
+  }
+
+  /**
+   * The same road for a vlvIndex: the write which untrusts it is the only one the change makes, and
+   * a failure of it is reported with the rebuild the change asked for, rather than thrown out of
+   * the listener with that result discarded. The change touches all four published fields at once,
+   * so none of them can be hoisted above the write and still leave this case green.
+   */
+  @Test
+  public void aChangedSortOrderIsReportedWhenTheWriteWhichUntrustsTheVlvIndexGivesUp() throws Exception
+  {
+    final ReplayingBackend backend = openBackendWithVlvIndex();
+    try
+    {
+      final RootContainer rootContainer = backend.getRootContainer();
+      final EntryContainer ec = rootContainer.getEntryContainer(KEPT);
+      final VLVIndex vlvIndex = ec.getVLVIndex(VLV_INDEX_NAME);
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName())).contains(TRUSTED);
+
+      // A base DN of the vlvIndex's own change, unrelated to REMOVED's meaning elsewhere in this suite.
+      final DN newBaseDN = DN.valueOf("dc=b907e,dc=com");
+      final BackendVLVIndexCfg changedCfg = vlvIndexCfg("+sn", newBaseDN, Scope.SINGLE_LEVEL, "(sn=*)");
+      backend.storage.failWithoutReplay();
+      final ConfigChangeResult ccr = vlvIndex.applyConfigurationChange(changedCfg);
+
+      assertThat(ccr.getResultCode()).isEqualTo(serverErrorResultCode());
+      assertThat(ccr.adminActionRequired())
+          .as("the rebuild the new sort order needs, on the road which failed").isTrue();
+      assertThat(ordinalsOf(ccr)).contains(NOTE_INDEX_ADD_REQUIRES_REBUILD.ordinal());
+      assertThat(ccr.getMessages().toString()).as("the failure, next to the rebuild")
+          .contains(UnreplayableFailure.class.getSimpleName());
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName()))
+          .as("what the restart will read").contains(TRUSTED);
+      assertThat((Object) vlvIndex.getBaseDN()).as("the base DN the failed write did not publish").isEqualTo(KEPT);
+      assertThat(vlvIndex.getScope()).as("the scope the failed write did not publish")
+          .isEqualTo(SearchScope.WHOLE_SUBTREE);
+      assertThat(vlvIndex.getFilter().toString()).as("the filter the failed write did not publish")
+          .isEqualTo("(objectClass=*)");
+      assertThat(vlvIndex.getSortKeys()).as("the definition the failed write did not publish")
+          .containsExactly(new SortKey("cn", false));
+
+      // The definition the failed change did not publish is still a change when asked for again.
+      final ConfigChangeResult again = vlvIndex.applyConfigurationChange(changedCfg);
+      assertThat(again.getResultCode()).isEqualTo(ResultCode.SUCCESS);
+      assertThat(again.adminActionRequired()).as("the definition the failed write did not publish").isTrue();
+      assertThat(persistedFlags(rootContainer, ec, vlvIndex.getName())).doesNotContain(TRUSTED);
+      assertThat((Object) vlvIndex.getBaseDN()).as("the base DN the write which committed published").isEqualTo(newBaseDN);
+      assertThat(vlvIndex.getScope()).as("the scope the write which committed published")
+          .isEqualTo(SearchScope.SINGLE_LEVEL);
+      assertThat(vlvIndex.getFilter().toString()).as("the filter the write which committed published")
+          .isEqualTo("(sn=*)");
+      assertThat(vlvIndex.getSortKeys()).as("the definition the write which committed published")
+          .containsExactly(new SortKey("sn", false));
+    }
+    finally
+    {
+      backend.finalizeBackend();
+    }
   }
 
   /** Reads back the flags an index was given when it was opened, as they are stored. */
@@ -576,9 +927,20 @@
 
   private ReplayingBackend openBackend(SortedSet<DN> baseDNs) throws Exception
   {
+    return openBackend(baseDNs, false);
+  }
+
+  /** The vlvIndex is opened only where a test is about one, since every base DN gets a copy of it. */
+  private ReplayingBackend openBackendWithVlvIndex() throws Exception
+  {
+    return openBackend(newTreeSet(KEPT), true);
+  }
+
+  private ReplayingBackend openBackend(SortedSet<DN> baseDNs, boolean withVlvIndex) throws Exception
+  {
     final ReplayingBackend backend = new ReplayingBackend();
     backend.setBackendID(BACKEND_ID);
-    backend.configuredWith = backendCfg(baseDNs);
+    backend.configuredWith = backendCfg(baseDNs, withVlvIndex);
     backend.configureBackend(backend.configuredWith, serverContext);
     // Start from a pristine on-disk state so that a previous run cannot mask the defect.
     backend.storage.removeStorageFiles();
@@ -618,6 +980,11 @@
 
   private PDBBackendCfg backendCfg(SortedSet<DN> baseDNs) throws ConfigException
   {
+    return backendCfg(baseDNs, false);
+  }
+
+  private PDBBackendCfg backendCfg(SortedSet<DN> baseDNs, boolean withVlvIndex) throws ConfigException
+  {
     final PDBBackendCfg cfg = mockCfg(PDBBackendCfg.class);
     when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + BACKEND_ID + ",cn=Backends,cn=config"));
     when(cfg.getBackendId()).thenReturn(BACKEND_ID);
@@ -627,17 +994,58 @@
     when(cfg.getDBCachePercent()).thenReturn(20);
     when(cfg.getBaseDN()).thenReturn(baseDNs);
     when(cfg.listBackendIndexes()).thenReturn(new String[] { "cn" });
-    when(cfg.listBackendVLVIndexes()).thenReturn(new String[0]);
-
-    final BackendIndexCfg indexCfg = mock(BackendIndexCfg.class);
-    when(indexCfg.getIndexType()).thenReturn(newTreeSet(IndexType.EQUALITY));
-    when(indexCfg.getAttribute()).thenReturn(cnType);
-    when(indexCfg.getIndexEntryLimit()).thenReturn(4000);
-    when(indexCfg.getSubstringLength()).thenReturn(6);
-    when(cfg.getBackendIndex("cn")).thenReturn(indexCfg);
+    // Built before it is handed over: stubbing a mock from inside a when() of another mock leaves
+    // that when() unfinished, and Mockito fails the next test to touch either of them.
+    final BackendIndexCfg cnIndexCfg = indexCfg(newTreeSet(IndexType.EQUALITY), 4000);
+    when(cfg.getBackendIndex("cn")).thenReturn(cnIndexCfg);
+    if (withVlvIndex)
+    {
+      final BackendVLVIndexCfg vlvCfg = vlvIndexCfg("+cn");
+      when(cfg.listBackendVLVIndexes()).thenReturn(new String[] { VLV_INDEX_NAME });
+      when(cfg.getBackendVLVIndex(VLV_INDEX_NAME)).thenReturn(vlvCfg);
+    }
+    else
+    {
+      when(cfg.listBackendVLVIndexes()).thenReturn(new String[0]);
+    }
     return cfg;
   }
 
+  private BackendIndexCfg indexCfg(SortedSet<IndexType> indexTypes, int indexEntryLimit)
+  {
+    final BackendIndexCfg cfg = mock(BackendIndexCfg.class);
+    when(cfg.getIndexType()).thenReturn(indexTypes);
+    when(cfg.getAttribute()).thenReturn(cnType);
+    when(cfg.getIndexEntryLimit()).thenReturn(indexEntryLimit);
+    when(cfg.getSubstringLength()).thenReturn(6);
+    return cfg;
+  }
+
+  private BackendVLVIndexCfg vlvIndexCfg(String sortOrder)
+  {
+    return vlvIndexCfg(sortOrder, KEPT, Scope.WHOLE_SUBTREE, "(objectClass=*)");
+  }
+
+  /** Varies the three fields a plain {@link #vlvIndexCfg(String)} change leaves alone, next to the sort order. */
+  private BackendVLVIndexCfg vlvIndexCfg(String sortOrder, DN baseDN, Scope scope, String filter)
+  {
+    final BackendVLVIndexCfg cfg = mock(BackendVLVIndexCfg.class);
+    when(cfg.getName()).thenReturn(VLV_INDEX_NAME);
+    when(cfg.getBaseDN()).thenReturn(baseDN);
+    when(cfg.getScope()).thenReturn(scope);
+    when(cfg.getFilter()).thenReturn(filter);
+    when(cfg.getSortOrder()).thenReturn(sortOrder);
+    return cfg;
+  }
+
+  /** An index of an empty backend is trusted when it is opened, whatever its tree holds. */
+  private static void addBaseEntry(ReplayingBackend backend, DN baseDN, String domainComponent) throws Exception
+  {
+    backend.addEntry(
+        TestCaseUtils.makeEntry("dn: " + baseDN, "objectClass: top", "objectClass: domain", "dc: " + domainComponent),
+        mock(AddOperation.class));
+  }
+
   /** A backend whose storage makes the next write operation conflict, and so be replayed. */
   private static final class ReplayingBackend extends BackendImpl<PDBBackendCfg>
   {
@@ -709,6 +1117,13 @@
     {
       /** As soon as the operation first touches the transaction, before it has changed anything. */
       FIRST_STORAGE_ACCESS,
+      /**
+       * As soon as the operation first touches the transaction, in the form the transaction of
+       * {@code PDBStorage} reports a conflict: a {@link StorageRuntimeException} wrapping the
+       * {@link RollbackException}. What an operation catching the former from inside sees, where
+       * {@link #FIRST_STORAGE_ACCESS} raises the bare conflict such a catch never meets.
+       */
+      FIRST_STORAGE_ACCESS_AS_THE_TRANSACTION_REPORTS_IT,
       /** Once the operation has run to completion, as a conflict reported by {@code commit()}. */
       COMMIT,
       /** Once the operation has run to completion, as a failure which is not replayed at all. */
@@ -723,6 +1138,8 @@
     private final Storage delegate;
     private Runnable onListTrees;
     private ConflictPoint conflictPoint;
+    /** Which write, counted over the life of this storage, is armed; zero for the next one. */
+    private int armedWrite;
     private int conflictsLeft;
     private int attempts;
     private int writes;
@@ -737,16 +1154,46 @@
       arm(ConflictPoint.FIRST_STORAGE_ACCESS, conflicts);
     }
 
+    /**
+     * Conflicts at the first storage access of the operation, in the form the transaction itself
+     * reports one: what the operation sees where it asks the transaction for something, rather than
+     * what the retry loop sees once the operation has let it through.
+     */
+    void conflictAsTheTransactionReportsIt(int conflicts)
+    {
+      arm(ConflictPoint.FIRST_STORAGE_ACCESS_AS_THE_TRANSACTION_REPORTS_IT, conflicts);
+    }
+
     void conflictAtCommit(int conflicts)
     {
       arm(ConflictPoint.COMMIT, conflicts);
     }
 
+    /**
+     * Conflicts at commit time on the {@code nth} write asked for from now on, the next one being
+     * the first: a configuration change which makes several writes has to arm the one it is about.
+     */
+    void conflictAtCommitOnWrite(int nth, int conflicts)
+    {
+      arm(ConflictPoint.COMMIT, conflicts);
+      armedWrite = writes + nth;
+    }
+
     void failWithoutReplay()
     {
       arm(ConflictPoint.NO_REPLAY, 1);
     }
 
+    /**
+     * Fails without a replay on the {@code nth} write asked for from now on, the next one being
+     * the first: what the last attempt of a retry loop which gave up leaves the caller holding.
+     */
+    void failWithoutReplayOnWrite(int nth)
+    {
+      arm(ConflictPoint.NO_REPLAY, 1);
+      armedWrite = writes + nth;
+    }
+
     void failAfterCommit()
     {
       arm(ConflictPoint.NO_REPLAY_AFTER_COMMIT, 1);
@@ -756,6 +1203,7 @@
     {
       conflictPoint = where;
       conflictsLeft = conflicts;
+      armedWrite = 0;
       attempts = 0;
     }
 
@@ -776,12 +1224,13 @@
     {
       writes++;
       final ConflictPoint armed = conflictPoint;
-      if (armed == null)
+      if (armed == null || (armedWrite != 0 && writes != armedWrite))
       {
         delegate.write(writeOperation);
         return;
       }
       conflictPoint = null;
+      armedWrite = 0;
       if (armed == ConflictPoint.NO_REPLAY_AFTER_COMMIT)
       {
         // Committed, then reported as a failure: the operation's work outlives the failure, as it
@@ -812,7 +1261,12 @@
           }
           if (armed == ConflictPoint.FIRST_STORAGE_ACCESS)
           {
-            writeOperation.run(new ConflictingTransaction());
+            writeOperation.run(new ConflictingTransaction(false));
+            return;
+          }
+          if (armed == ConflictPoint.FIRST_STORAGE_ACCESS_AS_THE_TRANSACTION_REPORTS_IT)
+          {
+            writeOperation.run(new ConflictingTransaction(true));
             return;
           }
           writeOperation.run(txn);
@@ -905,9 +1359,21 @@
   /** A transaction which conflicts as soon as it is used, without ever reaching the storage. */
   private static final class ConflictingTransaction implements WriteableTransaction
   {
-    private static RollbackException conflict()
+    /**
+     * Whether the conflict is raised as {@code PDBStorage}'s transaction raises it - wrapped in a
+     * {@link StorageRuntimeException}, which that storage's retry loop unwraps - or bare.
+     */
+    private final boolean asTheTransactionReportsIt;
+
+    ConflictingTransaction(boolean asTheTransactionReportsIt)
     {
-      return new RollbackException();
+      this.asTheTransactionReportsIt = asTheTransactionReportsIt;
+    }
+
+    private RuntimeException conflict()
+    {
+      final RollbackException conflict = new RollbackException();
+      return asTheTransactionReportsIt ? new StorageRuntimeException(conflict) : conflict;
     }
 
     @Override

--
Gitblit v1.10.0