mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday 8ad97160e90f47e5fffe0b8a8c65ee421fa0903c
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/TestCase.java
@@ -37,6 +37,10 @@
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
@@ -2200,6 +2204,181 @@
   }
   /**
    * A catalog table another session created while this one was creating it has to be read like any
    * other catalog that is already there. The create tolerates that race - an offline tool beside a
    * running server is a pair no lock of one process can order - and what it must not do is leave the
    * storage believing that table records nothing (#933): a storage believing that writes the row of
    * every tree it opens again, one upsert and one commit each, for the whole of that open.
    * <p>
    * The tree the case asks for afterwards is one the adopting write never opened, which is what
    * tells the two apart: a storage that read the table knows the catalog names it already, while one
    * that only wrote its way through the trees of that write knows those and nothing else.
    * <p>
    * What says which of the two it is, is the connection. The catalog of a transaction is opened at
    * the first row that transaction has to write - see {@link JDBCStorage.CatalogSession} - so a
    * write whose trees the catalog already names opens none at all, and one that has to record them
    * again opens one to write the rows that are already there.
    */
   @Test
   public void testAnOpenThatAdoptsTheCatalogTableOfAnotherSessionEnrolsNothingAgain() throws Exception {
      final TreeName opened = new TreeName("testAdoptedCatalog", "opened");
      final TreeName recorded = new TreeName("testAdoptedCatalog", "recorded");
      final JDBCStorage owner = new JDBCStorage(createBackendCfg(getBackendId() + "_adopted"), null);
      // the two are one backend, so the clear below drops what either of them left behind, and it is
      // constructed here so that a failure of the half that fills the catalog reaches that clear too:
      // nothing but @BeforeClass ever drops the tables a case leaves standing
      final AdoptingStorage racing = new AdoptingStorage(createBackendCfg(getBackendId() + "_adopted"));
      try {
         try {
            owner.open(AccessMode.READ_WRITE);
            owner.write(new WriteOperation() {
               @Override
               public void run(WriteableTransaction txn) throws Exception {
                  txn.openTree(opened, true); // the catalog table, and a row naming each of these trees
                  txn.openTree(recorded, true);
               }
            });
         } finally {
            owner.close();
         }
         racing.open(AccessMode.READ_WRITE);
         // armed after the open and not before it: what this models is the lookup openCatalog() makes,
         // and anything asking about the same table earlier would spend the one answer it has
         racing.hideOnce(racing.getTableName(racing.getCatalogTree()));
         racing.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
               txn.openTree(opened, true);
            }
         });
         assertTrue(racing.hidTheCatalogTable(), "the case never reached the create this is about");
         final int connectsOfTheAdoptingWrite = racing.catalogConnects();
         racing.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
               txn.openTree(recorded, true); // named by the catalog already, so there is no row to write
            }
         });
         assertEquals(racing.catalogConnects(), connectsOfTheAdoptingWrite,
            "a transaction whose tree the catalog already names opened a connection to write that row"
               + " again: the open which adopted the table another session created read nothing of what"
               + " that table records");
         assertTrue(racing.listTrees().contains(recorded), "the read of the adopted rows rewrote or removed them");
      } finally {
         clearQuietly(racing);
      }
   }
   /**
    * The road above pins the "adopted" answer of {@code createCatalogTable()}; this one pins the
    * "created" answer against the same mistake: an open that made the table itself has nothing of
    * its own to read back, the table it just made holding no row, and reading it anyway would be
    * one select and one commit spent on an empty catalog for every such open.
    */
   @Test
   public void testAnOpenThatCreatesTheCatalogTableReadsNothing() throws Exception {
      final AdoptingStorage fresh = new AdoptingStorage(createBackendCfg(getBackendId() + "_created"));
      try {
         fresh.open(AccessMode.READ_WRITE);
         fresh.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
               txn.openTree(new TreeName("testCreatedCatalog", "opened"), true);
            }
         });
         assertEquals(fresh.catalogReads(), 0, "the open that created the catalog table read it back");
      } finally {
         clearQuietly(fresh);
      }
   }
   /**
    * The adopt road again, on the arm where the failed create could not even be rolled back: the
    * session is given up rather than left holding a connection the database has dropped, and the
    * read of the rows that table already holds has to establish it again - the one connect this
    * class makes under {@code catalogLock}, which the invariant of {@code openCatalog()} names as
    * its exception.
    * <p>
    * A rollback refused once is the second state no case can reach from outside, beside the hidden
    * lookup it is armed with: a create that fails because its own connection is gone fails its
    * rollback with it. What says the read was made on a session established for it is the count of
    * connections the adopting write opened - the one the create ran on, and the one the read had to
    * make - and what says that read filled the memo all the same is the write after it, whose tree
    * the catalog already names and which therefore opens none.
    * <p>
    * The attempt is counted beside them, because {@code write()} would otherwise answer for this
    * road itself: a storage that gave the session up without letting go of it reads on a closed
    * connection, which is classified as a dropped connection and replayed, and the replay makes the
    * very table it failed to make usable. The counts above are then the counts of an attempt that
    * never met the race, so the case asks for the one attempt as well.
    */
   @Test
   public void testAnOpenThatAdoptsTheCatalogTableReadsItOnASessionItHadToEstablishAgain() throws Exception {
      final TreeName opened = new TreeName("testRefusedRollbackCatalog", "opened");
      final TreeName recorded = new TreeName("testRefusedRollbackCatalog", "recorded");
      final JDBCStorage owner = new JDBCStorage(createBackendCfg(getBackendId() + "_refused"), null);
      // one backend in two storages, as in the case above: the clear at the end drops what either of
      // them left behind, and constructing it here reaches that clear whichever half failed
      final AdoptingStorage racing = new AdoptingStorage(createBackendCfg(getBackendId() + "_refused"));
      try {
         try {
            owner.open(AccessMode.READ_WRITE);
            owner.write(new WriteOperation() {
               @Override
               public void run(WriteableTransaction txn) throws Exception {
                  txn.openTree(opened, true); // the catalog table, and a row naming each of these trees
                  txn.openTree(recorded, true);
               }
            });
         } finally {
            owner.close();
         }
         racing.open(AccessMode.READ_WRITE);
         // armed after the open, for the reason the case above arms its lookup there: the catalog of a
         // transaction is established at the first row it has to write, which is inside the write below
         racing.hideOnce(racing.getTableName(racing.getCatalogTree()));
         racing.refuseNextRollback();
         final int connectsBeforeTheAdoptingWrite = racing.catalogConnects();
         final AtomicInteger attemptsOfTheAdoptingWrite = new AtomicInteger();
         racing.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
               attemptsOfTheAdoptingWrite.incrementAndGet();
               txn.openTree(opened, true);
            }
         });
         assertTrue(racing.hidTheCatalogTable(), "the case never reached the create this is about");
         // a session the storage gave up and did not let go of is a select on a closed connection, which
         // write() classifies as a dropped connection and replays: the road recovers, and the counts
         // below are the counts of the attempt that replaced it. This is what says the read was made
         // inside the attempt that met the race, at the cost of the one connect and no replay at all
         assertEquals(attemptsOfTheAdoptingWrite.get(), 1,
            "the write which adopted the table was replayed: the read of the adopted rows has to be"
               + " made on a session established inside that attempt, not by spending another one");
         assertEquals(racing.catalogConnects(), connectsBeforeTheAdoptingWrite + 2,
            "the session the refused rollback gave up was not established again for the read of the"
               + " adopted rows: this road costs one connection for the create and one for that read");
         racing.write(new WriteOperation() {
            @Override
            public void run(WriteableTransaction txn) throws Exception {
               txn.openTree(recorded, true); // named by the catalog already, so there is no row to write
            }
         });
         assertEquals(racing.catalogConnects(), connectsBeforeTheAdoptingWrite + 2,
            "a transaction whose tree the catalog already names opened a connection to write that row"
               + " again: the read made on the re-established session recorded nothing of what the"
               + " adopted table holds");
         assertTrue(racing.listTrees().contains(recorded), "the read of the adopted rows rewrote or removed them");
      } finally {
         clearQuietly(racing);
      }
   }
   /**
    * A row of the catalog whose table is not there any more must not fail the clear, and must not
    * stop it dropping the rest. Nothing of the backend leaves such a row behind - deleteTree() takes
    * it out in the commit that drops the table - but a table dropped by hand, or a catalog restored
@@ -3012,4 +3191,120 @@
         fail(whatWentUnsaid + "; the clear reported: " + reported());
      }
   }
   /**
    * A storage which answers once that its catalog table is not there while it is, and counts the
    * connections its catalog is read and written on.
    * <p>
    * That answer models the one state no case can reach from outside: a session whose lookup ran a
    * moment before another session's "create table" committed, which goes on to create a table that
    * is already there and has to make what it finds usable all the same (#933). Every lookup after
    * it is the database's own answer, the way {@link ReportingStorage} lets its own go.
    * <p>
    * The count is what says whether the storage remembered what that table records: the catalog of a
    * transaction is opened at the first row the transaction has to write, so a write whose trees the
    * catalog already names opens no connection at all - see {@link JDBCStorage.CatalogSession}.
    * <p>
    * It refuses one rollback where a case asks for it, which is the other state no case reaches from
    * outside: the create of a session the database has dropped fails its rollback with it, and the
    * read of the adopted rows is then made on a session established again for it.
    */
   protected static final class AdoptingStorage extends JDBCStorage {
      private volatile String tableToHide;
      private volatile boolean hidden;
      private volatile boolean refuseNextRollback;
      private final AtomicInteger catalogConnects = new AtomicInteger();
      private final AtomicInteger catalogReads = new AtomicInteger();
      AdoptingStorage(JDBCBackendCfg cfg) {
         super(cfg, null);
      }
      /** Answers the next lookup of this table with "not there", whatever the database holds. */
      void hideOnce(String tableName) {
         tableToHide = tableName;
      }
      /** Whether that answer was given, so that a case cannot pass without having reached the race. */
      boolean hidTheCatalogTable() {
         return hidden;
      }
      /**
       * Hands the next catalog connection out with its first rollback refused: the state of a
       * session the database has dropped, whose create fails and whose rollback of that create fails
       * with it, which is what leaves {@code reset()} giving the session up instead of rolling it
       * back.
       */
      void refuseNextRollback() {
         refuseNextRollback = true;
      }
      /** How many connections this storage has opened its catalog on since it was constructed. */
      int catalogConnects() {
         return catalogConnects.get();
      }
      /** How many times this storage has read the catalog into its memo since it was constructed. */
      int catalogReads() {
         return catalogReads.get();
      }
      @Override
      boolean isExistsTable(Connection con, JDBCStorage.TableScope scope, String tableName) {
         final String hiding = tableToHide;
         if (hiding != null && hiding.equalsIgnoreCase(tableName)) {
            tableToHide = null; // once: the create it sends is answered by the table the database holds
            hidden = true;
            return false;
         }
         return super.isExistsTable(con, scope, tableName);
      }
      @Override
      Connection newCatalogConnection(long budgetDeadline) throws SQLException {
         catalogConnects.incrementAndGet();
         final Connection real = super.newCatalogConnection(budgetDeadline);
         if (!refuseNextRollback) {
            return real;
         }
         refuseNextRollback = false;
         return refusingItsFirstRollback(real);
      }
      /**
       * The connection above with one rollback of its own refused, and everything else the driver's.
       * The rollback of a whole transaction alone: a rollback to a savepoint is what the bound of a
       * DDL takes back where its setting failed ({@code withDdlLockBound}), and refusing that one
       * would arm a road other than the one this case is about.
       */
      private static Connection refusingItsFirstRollback(final Connection real) {
         return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(),
            new Class<?>[] { Connection.class }, new InvocationHandler() {
               private boolean refused;
               @Override
               public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                  if (!refused && "rollback".equals(method.getName()) && method.getParameterCount() == 0) {
                     refused = true;
                     throw new SQLException("the case refused the rollback of this catalog connection");
                  }
                  try {
                     return method.invoke(real, args);
                  } catch (InvocationTargetException e) {
                     throw e.getCause(); // the driver's own failure, and not a wrapper of the call
                  }
               }
            });
      }
      // the only caller of the two-argument overload is readEnrolledTrees(): catalogTables() (a
      // clear, or listTrees()) takes the three-argument one, so this counts a read of the memo and
      // nothing a clear does
      @Override
      Map<TreeName,String> readCatalogRows(Connection con, String catalogTable) throws SQLException {
         catalogReads.incrementAndGet();
         return super.readCatalogRows(con, catalogTable);
      }
   }
}