18 files modified
4 files added
| | |
| | | import java.util.concurrent.atomic.AtomicBoolean; |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | import java.util.concurrent.atomic.AtomicLong; |
| | | import java.util.function.LongFunction; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | |
| | |
| | | static final String POOL_TIMEOUT_PROPERTY = "org.openidentityplatform.opendj.jdbc.pool.timeout"; |
| | | static final long DEFAULT_POOL_TIMEOUT_SECONDS = 60; |
| | | |
| | | /** |
| | | * The socket read timeout a connection of this pool carries for the whole of its life, in |
| | | * seconds; 0 for none, which is what every connection of this backend carried before this |
| | | * property existed, and the default. |
| | | * <p> |
| | | * It bounds what neither of the two bounds before it reaches. The bound of |
| | | * {@value #CONNECT_TIMEOUT_PROPERTY} covers the connect and the login and is taken off as soon |
| | | * as the login is through, and the socket read timeout {@code JDBCStorage} arms behind a |
| | | * cancelled statement is armed for the length of that statement alone - so a commit, a |
| | | * rollback, a lookup of the catalog and the rows a cursor drains are all read from a socket |
| | | * with no deadline of any kind, and an operation that meets a database which stopped answering |
| | | * after the login stays parked. |
| | | * <p> |
| | | * The default is a consequence rather than caution: a bound standing on the connection has to |
| | | * exceed the longest silence the database may legitimately produce, and the class the longest |
| | | * statements belong to ({@code JDBCStorage.StatementBound.BULK}) ships unbounded for reasons of |
| | | * its own. There is no value here that does not contradict it - so the deployment that knows how |
| | | * long its database may go without answering is the one that sets this, and the statements of |
| | | * that class run with it taken off for as long as they do. |
| | | * <p> |
| | | * That silence is what this is sized against, rather than the longest statement, because the |
| | | * lift covers statements alone: the commit at the end of an import is a call of its own with no |
| | | * statement in flight behind it - {@code ImporterImpl.close()} commits a whole import in one - |
| | | * and so are the rollback of every borrow and the reads of the catalog. Every one of them runs |
| | | * under this bound whatever the class of the statements before it. It has to exceed the bound of |
| | | * an ordinary statement as well ({@code JDBCStorage.StatementBound.OPERATION}, two minutes by |
| | | * default): under it, such a statement dies on the socket at this value instead of being |
| | | * cancelled at its own, which costs the connection the driver then closes and reports neither |
| | | * property. A backend opening with the two set that way says so once. |
| | | * <p> |
| | | * A read bound standing in the connection string is the deployment's own and is left alone by |
| | | * every part of this: it is not replaced here, and it is not the one taken off there. A value of |
| | | * 0 there is no bound of theirs, though - it is the default of the driver, written out - and |
| | | * this one goes on top of it. |
| | | */ |
| | | static final String READ_TIMEOUT_PROPERTY = "org.openidentityplatform.opendj.jdbc.read.timeout"; |
| | | static final int DEFAULT_READ_TIMEOUT_SECONDS = 0; |
| | | |
| | | // Read once, at class initialization, for the reason aliveBypassNanos is: it is read on every |
| | | // connect and on every statement that has to take it off again, and neither is the place to |
| | | // parse a system property. Not final so that a test can vary it without a class loader of its |
| | | // own, and volatile because a non-final static is written neither atomically nor visibly to the |
| | | // threads reading it (JLS 17.7). |
| | | static volatile int readTimeoutMillis = getReadTimeoutMillis(); |
| | | |
| | | /** |
| | | * The bound of {@value #READ_TIMEOUT_PROPERTY} in milliseconds, which is the unit |
| | | * {@code setNetworkTimeout} takes. A value that is not a number, and a negative one, are |
| | | * reported once and ignored in favour of the default - a deployment that asked for a bound and |
| | | * misspelled it gets none, and that is the one thing this property exists to keep from |
| | | * happening quietly. A value past {@link JDBCStorage#MAX_BOUND_SECONDS} is taken down to it: the |
| | | * ceiling there is what a socket read timeout can hold at all, and a value beyond it would reach |
| | | * the driver as a negative timeout - outside the contract of the call, and a value a driver is |
| | | * free to read as anything. |
| | | */ |
| | | static int getReadTimeoutMillis() { |
| | | // Clamped against the ceiling rather than through JDBCStorage.clampSeconds(): that ceiling is |
| | | // a compile-time constant and reaches this class inlined, while the call would be a |
| | | // package-private call into another class - and this runs in the initializer of this one, |
| | | // which is loaded by whatever loader defines it. Across two loaders that is an |
| | | // IllegalAccessError rather than a call, and it would leave the class uninitializable. |
| | | final long seconds = Math.min(JDBCStorage.MAX_BOUND_SECONDS, |
| | | getNonNegativeProperty(READ_TIMEOUT_PROPERTY, DEFAULT_READ_TIMEOUT_SECONDS, "s")); |
| | | return (int) (seconds * 1000); |
| | | } |
| | | |
| | | /** |
| | | * The read bound this class put on a connection of this url, in milliseconds, or 0 for a |
| | | * connection carrying none of ours. What {@code JDBCStorage} has to know before it takes that |
| | | * bound off for a statement of a class that carries no bound of its own: a read timeout |
| | | * standing in the connection string is the deployment's own, and a driver whose property names |
| | | * are not known here was never given one - taking either off would leave the connection |
| | | * unbounded for the rest of its life in the pool, which is a bound taken away from a deployment |
| | | * that asked for one. |
| | | * <p> |
| | | * The dialect is read off the connection string here, the way {@link #getConnection} reads the |
| | | * one it hands {@link #connect}: the two have to answer the same, or the bound taken off would |
| | | * not be the bound that was set. |
| | | */ |
| | | static int standingReadBoundMillis(String connectionString) { |
| | | return connectionString == null ? 0 |
| | | : standingReadBoundMillis(connectionString, ConnectDialect.of(connectionString)); |
| | | } |
| | | |
| | | private static int standingReadBoundMillis(String connectionString, ConnectDialect dialect) { |
| | | final int millis = readTimeoutMillis; |
| | | if (millis <= 0 || dialect == null || dialect.bounds(connectionString, dialect.readProperties)) { |
| | | return 0; |
| | | } |
| | | return millis; |
| | | } |
| | | |
| | | /** Bound of the validation of a pooled connection: isValid(0) means "no timeout" in the JDBC contract. */ |
| | | static final int VALIDATION_TIMEOUT_SECONDS = 5; |
| | | |
| | |
| | | private static volatile Executor closer = DIRECT_EXECUTOR; |
| | | |
| | | /** |
| | | * Returns the bound of one attempt to establish a connection, as configured by the {@value |
| | | * #CONNECT_TIMEOUT_PROPERTY} system property; 0 for the operator asking for no bound of its own. |
| | | * A value beyond what a millisecond bound can carry is taken down to it: three of the four |
| | | * dialects state their properties in milliseconds, and a value that saturates the conversion |
| | | * bounds nothing. |
| | | * <p> |
| | | * Read here rather than at each connect so that every connection this backend establishes is |
| | | * bounded by the same configured value - the borrows of this pool and the connection {@code |
| | | * JDBCStorage} opens outside it for the tree catalog of a backend (#888) alike. A connect |
| | | * bounded tighter than the login of the deployment takes is a backend that stops opening, and |
| | | * one place to read the property is what keeps the two from drifting apart. |
| | | */ |
| | | static long getConnectTimeoutSeconds() { |
| | | return Math.min(getNonNegativeProperty(CONNECT_TIMEOUT_PROPERTY, DEFAULT_CONNECT_TIMEOUT_SECONDS, "s"), |
| | | Integer.MAX_VALUE / 1000); |
| | | } |
| | | |
| | | /** |
| | | * Returns the deadline of a whole borrow, as configured by the {@value #POOL_TIMEOUT_PROPERTY} |
| | | * system property; 0 for the operator asking for no deadline at all. |
| | | * <p> |
| | | * Read here for the reason the bound of a connect is: it is what a database taking no |
| | | * connection for the moment is waited out for, and the connection {@code JDBCStorage} opens |
| | | * outside this pool for the tree catalog of a backend (#888) waits it out for exactly as long - |
| | | * one property, one meaning, whichever of the two is asking. |
| | | */ |
| | | static long getPoolTimeoutSeconds() { |
| | | return getNonNegativeProperty(POOL_TIMEOUT_PROPERTY, DEFAULT_POOL_TIMEOUT_SECONDS, "s"); |
| | | } |
| | | |
| | | /** |
| | | * The moment a borrow of this length gives up, or {@link Long#MAX_VALUE} where it gives up |
| | | * never - a property of 0, and a value so large that the milliseconds of it would overflow. |
| | | * <p> |
| | | * The sum is guarded and not only the product: a value under the clamp above but large enough |
| | | * that the moment it names is past the end of the epoch would wrap to a deadline already behind |
| | | * us, and a borrow configured to wait practically forever would give up on its first retryable |
| | | * failure - the opposite of what was asked for. |
| | | */ |
| | | static long deadlineOf(long startedAt, long poolTimeoutSeconds) { |
| | | if (poolTimeoutSeconds == 0 || poolTimeoutSeconds >= Long.MAX_VALUE / 1000) { |
| | | return Long.MAX_VALUE; |
| | | } |
| | | final long deadline = startedAt + poolTimeoutSeconds * 1000; |
| | | return deadline < startedAt ? Long.MAX_VALUE : deadline; |
| | | } |
| | | |
| | | /** |
| | | * Returns the time after which an idle pooled connection is closed, as configured by the |
| | | * {@value #TTL_PROPERTY} system property. An invalid value is ignored in favor of the default. |
| | | * <p> |
| | |
| | | if (borrowers <= pool.max()) { |
| | | return; |
| | | } |
| | | final long poolTimeoutSeconds = getNonNegativeProperty(POOL_TIMEOUT_PROPERTY, DEFAULT_POOL_TIMEOUT_SECONDS, "s"); |
| | | // through the helper the borrows and the catalog connect both read it by: the same property |
| | | // has to mean the same thing wherever it is asked, and a clamp that helper grows the day the |
| | | // deadline needs one - getConnectTimeoutSeconds() already has one - must not be missed here |
| | | final long poolTimeoutSeconds = getPoolTimeoutSeconds(); |
| | | final String wait = poolTimeoutSeconds == 0 |
| | | ? "waits for one to be returned for as long as that takes" |
| | | : "waits up to " + poolTimeoutSeconds + "s for one to be returned and fails if none is"; |
| | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Whether one of these is bounded by the connection string, or by a system property this |
| | | * driver reads, as the bound of an established connection has to ask it. Told apart from |
| | | * {@link #declared} by what a 0 means: there the question is whether a property of ours is |
| | | * to be supplied to the connect at all, and on postgresql a parameter of the url outranks |
| | | * that property whatever it says - while a bound put on an established connection with |
| | | * setNetworkTimeout is outranked by nothing, so a "socketTimeout=0" is no bound of the |
| | | * deployment's to stay out of the way of. It is the default of the driver, written out, and |
| | | * reading it as theirs would leave a deployment that asked for a standing bound with none. |
| | | * <p> |
| | | * The names are recognized in the url the way {@link #declared} recognizes them, and out of |
| | | * the system properties from the same list - see the comment on that method. |
| | | */ |
| | | private boolean bounds(String connectionString, String... properties) { |
| | | for (final String property : properties) { |
| | | if (boundInUrl(connectionString, property) || setAsSystemProperty(property)) { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Whether the connection string bounds this property, under its own name or the last segment |
| | | * of it. The two names are asked independently, the way {@link #declaredInUrl} asks them: |
| | | * stopping at the first name present, even where its value is a zero, let a |
| | | * "...?oracle.jdbc.ReadTimeout=0&ReadTimeout=600" answer with the zero of the name that comes |
| | | * first and hide the bound standing behind it. Such a url is declared() and would then not |
| | | * be bounds(): the login keeps the administrator's 600 s and one of ours goes on top of it |
| | | * with setNetworkTimeout. The two predicates have to look at the same set of names for "the |
| | | * bound taken off is the bound that was set" to hold. |
| | | */ |
| | | private boolean boundInUrl(String connectionString, String property) { |
| | | if (isBound(parameterValue(connectionString, property))) { |
| | | return true; |
| | | } |
| | | final int dot = property.lastIndexOf('.'); |
| | | return dot >= 0 && isBound(parameterValue(connectionString, property.substring(dot + 1))); |
| | | } |
| | | |
| | | // Whether the administrator bounded one of these properties themselves. The dialects |
| | | // separate their parameters differently - "?a=1&b=2" (postgresql, mysql), ";a=1;b=2" (sql |
| | | // server), "(A=1)" inside the descriptor of an oracle tns url, where the property also goes |
| | |
| | | final Pool pool = poolOf(connectionString); |
| | | final ConnectDialect dialect = ConnectDialect.of(connectionString); |
| | | reportUnknownDialect(connectionString, dialect); |
| | | final long connectTimeoutSeconds = Math.min( |
| | | getNonNegativeProperty(CONNECT_TIMEOUT_PROPERTY, DEFAULT_CONNECT_TIMEOUT_SECONDS, "s"), |
| | | Integer.MAX_VALUE / 1000); |
| | | final long poolTimeoutSeconds = getNonNegativeProperty(POOL_TIMEOUT_PROPERTY, DEFAULT_POOL_TIMEOUT_SECONDS, "s"); |
| | | final long connectTimeoutSeconds = getConnectTimeoutSeconds(); |
| | | final long poolTimeoutSeconds = getPoolTimeoutSeconds(); |
| | | final long ttlMillis = getCacheTtlMillis(); |
| | | final long startedAt = System.currentTimeMillis(); |
| | | final long deadline = (poolTimeoutSeconds == 0 || poolTimeoutSeconds >= Long.MAX_VALUE / 1000) |
| | | ? Long.MAX_VALUE : startedAt + poolTimeoutSeconds * 1000; |
| | | final long deadline = deadlineOf(startedAt, poolTimeoutSeconds); |
| | | // A thread already holding a connection is not made to wait for one: the two are held at |
| | | // the same time, so waiting for the first to come back would wait for itself. |
| | | final boolean reentrant = pool.heldByCurrentThread(); |
| | |
| | | * are the ones of a driver, so a driver outside the four leaves every attempt unbounded - and |
| | | * the deadline of the borrow cannot reach into a connect that is already under way, since the |
| | | * driver is the only thing holding the socket. |
| | | * <p> |
| | | * The connect is not the whole of it. {@value #READ_TIMEOUT_PROPERTY} is not put on the |
| | | * connections of such a pool either: {@link #standingReadBoundMillis(String)} answers 0 for a |
| | | * dialect this class does not know, so a read timeout the url may already carry under a name of |
| | | * its own is left alone rather than covered by one of ours. That silence is what this says out |
| | | * loud, since the strict parsing of that property exists precisely so that a deployment which |
| | | * asked for a bound is never quietly left with none. |
| | | */ |
| | | private static void reportUnknownDialect(String connectionString, ConnectDialect dialect) { |
| | | if (dialect != null) { |
| | |
| | | for (final ConnectDialect candidate : ConnectDialect.values()) { |
| | | known.append(known.length() > 0 ? ", " : "").append(candidate.urlPrefix); |
| | | } |
| | | // Only where one was asked for: a deployment running on the default of that property asked |
| | | // for no standing bound anywhere, and has nothing to act on here. |
| | | final String standingBound = readTimeoutMillis > 0 |
| | | ? ", and the " + READ_TIMEOUT_PROPERTY + " asked for is not put on the connections of this pool" |
| | | + " either - a read of one whose database stops answering after the login waits with no deadline" |
| | | + " able to reach it. Such a url may bound the read under a name this backend does not know, which" |
| | | + " is why none is set on top of it: bound it in the url instead" |
| | | : ""; |
| | | warnOnce(safeUrl(connectionString) + "|unknown-dialect", |
| | | "%s names a driver whose timeout properties are not known to this backend (%s are): a connect to a" |
| | | + " database that accepts it and does not answer is left without a bound, and the %s property" |
| | | + " cannot end it", |
| | | safeUrl(connectionString), known, POOL_TIMEOUT_PROPERTY); |
| | | + " cannot end it%s", |
| | | safeUrl(connectionString), known, POOL_TIMEOUT_PROPERTY, standingBound); |
| | | } |
| | | |
| | | /** |
| | |
| | | // still under the read bound: both of these are round trips of their own |
| | | conNew.setAutoCommit(false); |
| | | conNew.setTransactionIsolation(TRANSACTION_READ_COMMITTED); |
| | | if (readBoundSet) { |
| | | // a driver that will not take the bound back has warned about it already: the |
| | | // connection serves the borrower that is waiting for it and is closed rather than |
| | | // pooled, so the bound of the login does not outlive it in the pool |
| | | poolable = relaxReadBound(conNew, connectTimeoutSeconds); |
| | | } |
| | | // whatever the driver will not take here it has warned about already: a connection left |
| | | // carrying the read bound of its login serves the borrower that is waiting for it and is |
| | | // closed rather than pooled, so that bound does not outlive it in the pool |
| | | poolable = applyStandingReadBound(conNew, connectionString, dialect, connectTimeoutSeconds, readBoundSet); |
| | | } catch (SQLException | RuntimeException e) { // nothing holds this connection yet: it would leak |
| | | closeQuietly(conNew); |
| | | throw e; |
| | |
| | | return established; |
| | | } |
| | | |
| | | // The second bound of the login is a socket read timeout on mysql, oracle and sql server, in |
| | | // force for the whole life of the connection: left in place it would break every statement |
| | | // slower than it - an import batch, the statistics of a freshly loaded table - so it is lifted |
| | | // as soon as the login is through, restoring the behaviour of a connection this class |
| | | // established before. A read bound the connection string sets itself is never touched here: |
| | | // it is not set at all, so nothing of the administrator's is lifted along with it. Returns |
| | | // whether the bound is gone - a connection still carrying it must not be pooled. |
| | | // Named by the bound the login was given rather than by the property it came from: with |
| | | // CONNECT_TIMEOUT_PROPERTY at 0 the attempt takes its bound from what is left of the deadline |
| | | // of the borrow, so naming that property would point at the one setting that is not in force. |
| | | private static boolean relaxReadBound(Connection con, long boundSeconds) { |
| | | return setNetworkTimeout(con, 0, "statements taking longer than the " + boundSeconds |
| | | + "s the login of this connection was bounded by fail on it, and it is closed rather than pooled"); |
| | | /** |
| | | * Gives an established connection the read bound it carries from here on, which is the same |
| | | * call that takes the read bound of its login off. |
| | | * <p> |
| | | * The second is not optional. On mysql, oracle and sql server the second bound of the login is |
| | | * a socket read timeout in force for the whole life of the connection, and left in place it |
| | | * breaks every statement slower than a connect - an import batch, the statistics of a freshly |
| | | * loaded table. What replaces it is {@value #READ_TIMEOUT_PROPERTY}, or the 0 this class has |
| | | * always put here where a deployment asks for nothing. |
| | | * <p> |
| | | * A read bound the connection string sets itself is neither replaced nor lifted: it was not set |
| | | * by us at the login either, so nothing of the administrator's is touched here. |
| | | * <p> |
| | | * Called whether or not the login had a bound to lift, since the standing bound is not the |
| | | * login's: with {@value #CONNECT_TIMEOUT_PROPERTY} at 0 the login is bounded by what is left of |
| | | * the deadline of the borrow instead, and a deployment running that way would otherwise set the |
| | | * property here and get nothing for it. |
| | | * <p> |
| | | * Returns whether the connection may be pooled. A connection still carrying the bound of its |
| | | * login must not be: it would fail the statements of every borrower after this one. A |
| | | * connection that merely never took the standing bound may be - that is the connection this |
| | | * pool handed out before the property existed. |
| | | */ |
| | | private static boolean applyStandingReadBound(Connection con, String connectionString, ConnectDialect dialect, |
| | | long loginBoundSeconds, boolean readBoundSet) { |
| | | final int millis = standingReadBoundMillis(connectionString, dialect); |
| | | if (millis == 0 && !readBoundSet) { |
| | | return true; // nothing of ours on this connection: nothing to set here, and nothing to lift |
| | | } |
| | | final String consequence = readBoundSet |
| | | ? "statements taking longer than the " + loginBoundSeconds |
| | | + "s the login of this connection was bounded by fail on it, and it is closed rather than pooled" |
| | | : "this connection carries no read bound of its own, so a read of it the database stops answering waits" |
| | | + " with no deadline able to reach it"; |
| | | return setNetworkTimeout(con, millis, consequence) || !readBoundSet; |
| | | } |
| | | |
| | | /** |
| | |
| | | // is indistinguishable from a hang. Throttled, since every operation of the backend borrows |
| | | // through here and would otherwise log a copy of its own. |
| | | private static void warnStall(String connectionString, int attempts, long startedAt, SQLException cause) { |
| | | warnStall(connectionString, "", startedAt, |
| | | waitedMs -> stallMessage(connectionString, attempts, waitedMs, cause)); |
| | | } |
| | | |
| | | /** |
| | | * The same for a connect this class makes for somebody outside the pool - the connection the |
| | | * tree catalog of a backend is written on (#888) - which waits for no pooled connection and |
| | | * must not be described as one. |
| | | * <p> |
| | | * Throttled apart from the borrows of the same url as well as worded apart from them: the two |
| | | * stall on the same database for the same reason, so a borrow that warned a moment ago would |
| | | * otherwise silence the connect that is about to fail - the one of the two an operator has no |
| | | * other line about. |
| | | */ |
| | | static void warnStallOutsidePool(String connectionString, String what, int attempts, long startedAt, |
| | | SQLException cause) { |
| | | warnStall(connectionString, "|" + what, startedAt, |
| | | waitedMs -> outsidePoolStallMessage(connectionString, what, attempts, waitedMs, cause)); |
| | | } |
| | | |
| | | private static void warnStall(String connectionString, String throttleKeySuffix, long startedAt, |
| | | LongFunction<String> message) { |
| | | final long now = System.currentTimeMillis(); |
| | | if (stallWarningDue(connectionString, throttleKeySuffix, startedAt, now)) { |
| | | logger.warn(LocalizableMessage.raw("%s", message.apply(now - startedAt))); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Whether a stall of this wait is to be reported now: a wait shorter than |
| | | * {@link #STALL_WARNING_AFTER_MS} is no stall yet, and one already reported for this url within |
| | | * {@link #STALL_WARNING_INTERVAL_MS} is not reported again - every worker thread of a server |
| | | * meets a database taking no connection at the same moment, and one line an interval is what |
| | | * an operator can read. |
| | | * <p> |
| | | * Built apart from the logging of it for the reason {@link #stallMessage} is: what it has to |
| | | * keep is a rule a test can hold it to, and the shipped path reaches the throttle only where a |
| | | * connect really has stalled for a second. The suffix is what keeps the two waits apart - a |
| | | * borrow of the pool and the connect the tree catalog of a backend is made on (#888) stall on |
| | | * the same database for the same reason, and a borrow that reported a moment ago must not |
| | | * silence the connect that is about to fail, which has no other line about it at all. |
| | | * <p> |
| | | * Filing the moment is part of deciding it, so that two threads asking at once report once. |
| | | */ |
| | | static boolean stallWarningDue(String connectionString, String throttleKeySuffix, long startedAt, long now) { |
| | | if (now - startedAt < STALL_WARNING_AFTER_MS) { |
| | | return; |
| | | return false; |
| | | } |
| | | final AtomicLong lastOfThisUrl = |
| | | lastStallWarning.computeIfAbsent(safeUrl(connectionString), url -> new AtomicLong()); |
| | | lastStallWarning.computeIfAbsent(safeUrl(connectionString) + throttleKeySuffix, url -> new AtomicLong()); |
| | | final long last = lastOfThisUrl.get(); |
| | | if (now - last >= STALL_WARNING_INTERVAL_MS && lastOfThisUrl.compareAndSet(last, now)) { |
| | | logger.warn(LocalizableMessage.raw("%s", stallMessage(connectionString, attempts, now - startedAt, cause))); |
| | | } |
| | | return now - last >= STALL_WARNING_INTERVAL_MS && lastOfThisUrl.compareAndSet(last, now); |
| | | } |
| | | |
| | | /** |
| | | * The stall of a connect made outside the pool, as it reaches the log. Built apart from the |
| | | * logging of it for the reason {@link #stallMessage} is: the rule it has to keep - neither the |
| | | * connection string nor the message of the driver reaches a log as it stands - is a rule a test |
| | | * can hold it to. |
| | | */ |
| | | static String outsidePoolStallMessage(String connectionString, String what, int attempts, long waitedMs, |
| | | SQLException cause) { |
| | | return String.format("%s takes no further connection: the %s connection of this backend is opened outside the" |
| | | + " pool and has been retrying for %d ms (%d attempts), last error: %s", safeUrl(connectionString), what, |
| | | waitedMs, attempts, redact(cause.getMessage(), connectionString)); |
| | | } |
| | | |
| | | /** |
| | |
| | | import org.forgerock.opendj.config.server.ConfigurationChangeListener; |
| | | import org.forgerock.opendj.ldap.ByteSequence; |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.opendj.ldap.DN; |
| | | import org.forgerock.opendj.server.config.server.JDBCBackendCfg; |
| | | import org.opends.server.backends.pluggable.spi.*; |
| | | import org.opends.server.core.ServerContext; |
| | |
| | | |
| | | import java.io.Closeable; |
| | | import java.nio.ByteBuffer; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | import java.security.NoSuchAlgorithmException; |
| | | import java.sql.*; |
| | |
| | | |
| | | private JDBCBackendCfg config; |
| | | |
| | | /** Not read yet: it follows {@link #poolKey()}, which a close and a re-open may leave naming another database. */ |
| | | private static final int STANDING_READ_BOUND_UNREAD = -1; |
| | | private volatile int standingReadBound = STANDING_READ_BOUND_UNREAD; |
| | | |
| | | /** |
| | | * The read bound this backend puts on its connections at their login, in milliseconds, or 0 |
| | | * where it puts none - what {@link #applyBackstop} takes off a connection for the length of a |
| | | * statement that carries no bound of its own. Read once and remembered rather than per |
| | | * statement: it follows a system property and the connection string of the pool, and neither of |
| | | * them changes under a running statement. |
| | | * <p> |
| | | * Resolved against {@link #poolKey()} rather than against the configuration as it stands, for |
| | | * the reason {@link #getConnection(boolean)} borrows on that one: db-directory may be changed on |
| | | * a running backend, and the connections whose bound this decides are the ones of the pool |
| | | * {@link #open(AccessMode)} registered with. Read off the url the configuration names now, the |
| | | * lift would be decided for a pool this storage never borrows from - leaving the bound of this |
| | | * backend standing on a statement of an unbounded class, which is what {@code bulk.timeout=0} |
| | | * promises will not happen, or taking a bound of the deployment's own off the connections it |
| | | * really borrows. |
| | | * <p> |
| | | * Resolved once and for all in {@link #open(AccessMode)} rather than left to the first statement |
| | | * that asks: {@link #applyBackstop} is the only caller in production, and it asks only behind a |
| | | * statement of a class carrying no bound of its own - a deployment that gives {@code |
| | | * bulk.timeout} a value has no such statement anywhere, and would never be told that its two |
| | | * bounds are set the wrong way round. |
| | | */ |
| | | int standingReadBoundMillis() { |
| | | int millis=standingReadBound; |
| | | if (millis < 0) { |
| | | millis=CachedConnection.standingReadBoundMillis(poolKey()); |
| | | reportABoundNoStatementCanOutlive(millis); |
| | | standingReadBound=millis; |
| | | } |
| | | return millis; |
| | | } |
| | | |
| | | /** |
| | | * Whether a standing read bound cuts a statement carrying a bound of its own short of it. Such |
| | | * a statement then dies on the socket - which costs the connection the driver closes, and names |
| | | * neither of the two properties that decided it - instead of being cancelled at the bound of its |
| | | * own class. A statement of a class with no bound at all is not weighed here: {@link |
| | | * #applyBackstop} takes the standing bound off for as long as one of those runs. |
| | | * <p> |
| | | * Weighed against {@link #backstopMillis} of that bound rather than against the bound itself, |
| | | * because the cancel is not always there to come first: the catalog lookups of {@code openTree()} |
| | | * ask {@code DatabaseMetaData}, which takes no query timeout at all, and any driver is free to |
| | | * refuse one. What ends such a statement is the socket layer of its own class, a margin later, |
| | | * and a standing bound anywhere below that ends it earlier - with {@link #applyBackstop} arming |
| | | * nothing on top of it, since the connection already carries the tighter of the two, so the |
| | | * failure arrives naming neither property. |
| | | */ |
| | | static boolean cutsStatementsShort(int standingMillis, int statementSeconds) { |
| | | return standingMillis > 0 && statementSeconds > 0 && standingMillis <= backstopMillis(statementSeconds); |
| | | } |
| | | |
| | | /** The loosest bound a statement of this backend carries, and the property that gives it. */ |
| | | static final class LoosestBound { |
| | | final int seconds; |
| | | final String property; |
| | | |
| | | LoosestBound(int seconds, String property) { |
| | | this.seconds = seconds; |
| | | this.property = property; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The loosest bound a statement of this backend may be given: what a standing read bound has to |
| | | * stand behind, since every one of those statements was told it may take that long. |
| | | * <p> |
| | | * Not {@link StatementBound#OPERATION} alone. The statistics refresh after an import has a |
| | | * property of its own, ten minutes by default, and legitimately takes as long as a scan of the |
| | | * table it describes; and a deployment that gives {@link StatementBound#BULK} a value takes that |
| | | * class out of the lift of {@link #applyBackstop} and into this weighing, since a bulk statement |
| | | * bounded by a property is a statement the standing bound can cut short like any other. |
| | | */ |
| | | static LoosestBound loosestStatementBound() { |
| | | int seconds=statisticsTimeoutSeconds(); |
| | | String property=STATISTICS_TIMEOUT_PROPERTY; |
| | | for (final StatementBound bound : StatementBound.values()) { |
| | | final int boundSeconds=bound.seconds(); |
| | | if (boundSeconds > seconds) { |
| | | seconds=boundSeconds; |
| | | property=bound.property; |
| | | } |
| | | } |
| | | return new LoosestBound(seconds, property); |
| | | } |
| | | |
| | | /** |
| | | * Says once that the two bounds were set the wrong way round. The socket read timeout is the |
| | | * layer behind the cancel of a statement, not in front of it: under the bound of the statement |
| | | * it is the one that fires, and what the operator then sees is a connection closed by its driver |
| | | * under a bare state of class 08 - {@link #timedOut} weighs the statement against its own bound, |
| | | * finds it well inside, and passes the failure through as it found it. |
| | | * <p> |
| | | * Said where the backend opens rather than where the bound is first needed, and weighed against |
| | | * {@link #loosestStatementBound()}: a deployment reaches this the moment it configures the two |
| | | * the wrong way round, whatever kind of statement it goes on to run. |
| | | */ |
| | | private void reportABoundNoStatementCanOutlive(int millis) { |
| | | final LoosestBound loosest=loosestStatementBound(); |
| | | if (cutsStatementsShort(millis, loosest.seconds) && standingReadBoundWarned.compareAndSet(false, true)) { |
| | | logger.warn(LocalizableMessage.raw("jdbc: the read bound of %s is %d ms, which a statement of this backend" |
| | | + " reaches before the %d s of %s it is given: such a statement is cut by the socket read timeout," |
| | | + " closing the connection and naming neither property, rather than being cancelled at the bound of" |
| | | + " its own class. A standing read bound stands behind the bound of a statement - behind the %d s" |
| | | + " margin of that layer as well, since the cancel in front of it is one a driver may refuse and one" |
| | | + " the catalog lookups of a tree are never given - so it has to be the longer of the two", |
| | | CachedConnection.READ_TIMEOUT_PROPERTY, millis, loosest.seconds, loosest.property, |
| | | BACKSTOP_MARGIN_SECONDS)); |
| | | } |
| | | } |
| | | |
| | | public JDBCStorage(JDBCBackendCfg cfg, ServerContext serverContext) { |
| | | this.config = cfg; |
| | | cfg.addJDBCChangeListener(this); |
| | |
| | | try |
| | | { |
| | | this.config = cfg; |
| | | // The standing read bound is deliberately not reset here. It follows poolKey() - the |
| | | // connection string open() registered the pool with, not the one config names now - so a |
| | | // db-directory changed under a running backend does not move it. Reset, it would be |
| | | // resolved again under a lift already in flight: applyBackstop() would find the answer of |
| | | // another url for the connection whose read bound it has just taken off, fall through to |
| | | // giveBack() and hand that bound back to the statements of an unbounded class still |
| | | // running on it - the failure the lift exists to prevent, and one naming no property. |
| | | // What does move it is a close and a re-open, which is where it is reset (releasePool()). |
| | | } |
| | | catch (Exception e) |
| | | { |
| | |
| | | return 0; // no connection to arm it on: the cancel is the whole bound of such a statement |
| | | } |
| | | synchronized (state) { |
| | | return state.armed; |
| | | return state.applied != null ? state.applied : 0; // a lift is a zero either way: nothing bounds it |
| | | } |
| | | } |
| | | |
| | |
| | | private final AtomicBoolean backstopUnsupportedWarned = new AtomicBoolean(); |
| | | private final AtomicBoolean backstopFailedWarned = new AtomicBoolean(); |
| | | private final AtomicBoolean queryTimeoutWarned = new AtomicBoolean(); |
| | | private final AtomicBoolean standingReadBoundWarned = new AtomicBoolean(); |
| | | |
| | | /** |
| | | * The socket read timeout of one connection, and the statements running on it. This second |
| | |
| | | int unbounded; |
| | | /** Statements holding this entry, bounded or not: at zero it leaves {@link #backstops}. */ |
| | | int holders; |
| | | /** What the connection carried before the backstop armed it, and is given back afterwards. */ |
| | | /** What the connection carried before the backstop touched it, and is given back afterwards. */ |
| | | int previous; |
| | | /** What the backstop has armed, or 0 when the connection carries {@link #previous}. */ |
| | | int armed; |
| | | /** |
| | | * What this backstop has put on the connection: {@code null} where it has put nothing and the |
| | | * connection carries {@link #previous} of its own, 0 where the read bound is taken off for a |
| | | * statement carrying none, and the value armed otherwise. |
| | | * <p> |
| | | * One field rather than a value beside a flag, because "nothing of ours is on this |
| | | * connection" and "our lift is on it" are both a zero of that value: told apart by a boolean |
| | | * beside it, the pair has to be tested together at every site that gives the connection back, |
| | | * and an invariant spelled out at four sites is one three of them can be left out of. |
| | | */ |
| | | Integer applied; |
| | | /** |
| | | * Set when the driver would not take a network timeout on this connection: it is not asked |
| | | * again while the statements holding this entry run. A connection is the right scope for |
| | |
| | | return (int) Math.min(Integer.MAX_VALUE, (seconds+BACKSTOP_MARGIN_SECONDS)*1000L); |
| | | } |
| | | |
| | | /** Whether the read bound of this connection is the one this backstop took off for a statement carrying none. */ |
| | | private static boolean lifted(Backstop state) { |
| | | return state.applied != null && state.applied == 0; |
| | | } |
| | | |
| | | /** |
| | | * Makes the socket read timeout of the connection what the statements in flight on it need: the |
| | | * loosest of their bounds, or nothing of ours at all while one of them carries no bound. Called |
| | |
| | | final int wanted=state.unbounded > 0 || state.bounds.isEmpty() ? 0 : state.bounds.lastKey(); |
| | | try { |
| | | if (wanted == 0) { |
| | | if (state.armed != 0) { |
| | | con.setNetworkTimeout(DIRECT_EXECUTOR, state.previous); |
| | | state.armed=0; |
| | | // A statement of an unbounded class is running, and the connection carries the read |
| | | // bound this backend gave it at its login (CachedConnection.READ_TIMEOUT_PROPERTY): |
| | | // that bound comes off for as long as the statement does, since a statement told it |
| | | // may take as long as it needs must not be cut by a value armed for another one. |
| | | // Only ours is taken off - a read timeout standing in the connection string is the |
| | | // deployment's own, and lifting it would hand the connection back to the pool with |
| | | // the one bound its url asked for gone. |
| | | if (state.unbounded > 0 && standingReadBoundMillis() > 0) { |
| | | if (state.applied == null) { |
| | | state.previous=con.getNetworkTimeout(); |
| | | } |
| | | if (state.previous > 0) { |
| | | if (!lifted(state)) { // whether this backstop had armed a value or put nothing on at all |
| | | con.setNetworkTimeout(DIRECT_EXECUTOR, 0); |
| | | state.applied=0; |
| | | } |
| | | return; |
| | | } |
| | | } |
| | | giveBack(con, state); |
| | | return; |
| | | } |
| | | if (state.armed == 0) { |
| | | // What the connection carried is read once and remembered until it is given back. Read |
| | | // again while the lift above holds, it would be the 0 of that lift - and the read bound |
| | | // of the connection would go back to the pool gone for the rest of its life, which is |
| | | // how a statement of an unbounded class outliving a bounded one on the same connection |
| | | // takes the deployment's bound away for good. |
| | | if (state.applied == null) { |
| | | state.previous=con.getNetworkTimeout(); |
| | | } |
| | | // only ever tighten: a connection that already carries a read timeout carries one a |
| | | // deployment asked for, and this backstop exists to cap a cancel that is not acted |
| | | // upon, not to relax anything. 0 is "no timeout" in the JDBC contract, so it is the |
| | | // one value there is always something to gain by replacing. |
| | | // deployment asked for - the bound standing in its url, or the standing bound of |
| | | // CachedConnection.READ_TIMEOUT_PROPERTY this backend set at its login on their behalf - |
| | | // and this backstop exists to cap a cancel that is not acted upon, not to relax |
| | | // anything. The standing bound being ours to set makes it no less theirs to keep: it is |
| | | // sized to stand behind every statement of this backend, and one that does not is said |
| | | // where the backend opens (reportABoundNoStatementCanOutlive) rather than quietly worked |
| | | // around here, which would leave the property meaning something other than what it says. |
| | | // 0 is "no timeout" in the JDBC contract, so it is the one value there is always |
| | | // something to gain by replacing. |
| | | if (state.previous > 0 && state.previous <= wanted) { |
| | | if (state.armed != 0) { |
| | | con.setNetworkTimeout(DIRECT_EXECUTOR, state.previous); |
| | | state.armed=0; |
| | | } |
| | | giveBack(con, state); |
| | | return; |
| | | } |
| | | if (state.armed != wanted) { |
| | | if (state.applied == null || state.applied != wanted) { |
| | | con.setNetworkTimeout(DIRECT_EXECUTOR, wanted); |
| | | state.armed=wanted; |
| | | state.applied=wanted; |
| | | } |
| | | }catch (SQLException | RuntimeException e) { |
| | | state.failed=true; // whatever the cause, this connection is not asked again while it runs |
| | |
| | | } |
| | | |
| | | /** |
| | | * Gives the connection back the read timeout it carried before this backstop armed one, and |
| | | * forgets having armed it. Best effort by construction: the caller reaches this from a driver |
| | | * call that has just failed, so the connection may well be gone - and where it is, it is the |
| | | * driver that closes it rather than this backend. |
| | | * Gives the connection back the read timeout it carried before this backstop touched it - |
| | | * whether that was a bound armed for a statement or the lift of one that carries none - and |
| | | * forgets having touched it. Nothing to do for a connection this backstop left alone. |
| | | */ |
| | | private static void giveBack(Connection con, Backstop state) throws SQLException { |
| | | if (state.applied == null) { |
| | | return; // the connection carries its own value already |
| | | } |
| | | con.setNetworkTimeout(DIRECT_EXECUTOR, state.previous); |
| | | state.applied=null; |
| | | } |
| | | |
| | | /** |
| | | * The same, best effort by construction: the caller reaches this from a driver call that has |
| | | * just failed, so the connection may well be gone - and where it is, it is the driver that |
| | | * closes it rather than this backend. |
| | | */ |
| | | private static void restorePrevious(Connection con, Backstop state) { |
| | | if (state.armed == 0) { |
| | | if (state.applied == null) { |
| | | return; // the connection carries its own value already |
| | | } |
| | | try { |
| | |
| | | // nothing further can be done for this connection here, and the failure to report is the |
| | | // one that brought us into the catch above |
| | | }finally { |
| | | state.armed=0; |
| | | state.applied=null; |
| | | } |
| | | } |
| | | |
| | |
| | | CachedConnection.openPool(poolConnectionString); |
| | | registeredHere=true; |
| | | } |
| | | // Resolved here, against the connection string the pool was just registered with, rather |
| | | // than left to the first statement that needs it: applyBackstop() is the only caller in |
| | | // production and reaches it only behind a statement of a class carrying no bound of its |
| | | // own, so a deployment that gives bulk.timeout a value of its own reaches it nowhere at |
| | | // all - and the word reportABoundNoStatementCanOutlive() owes an operator whose two |
| | | // bounds are set the wrong way round would never be said. Costs one system property and |
| | | // one scan of the url, once per open. |
| | | standingReadBoundMillis(); |
| | | // The validated borrow is the whole of the open, and nothing is taken from it here: the |
| | | // status is set below rather than inside the block, or a throw from the implicit close() |
| | | // - the rollback of the return goes to the database - would leave the storage reporting |
| | |
| | | // touching the pool: left standing it would send the close() of this storage to |
| | | // releasePool() for a registration it never made. |
| | | poolConnectionString=null; |
| | | standingReadBound=STANDING_READ_BOUND_UNREAD; // it follows poolKey(), which the next open may register elsewhere |
| | | poolRegistered.set(false); |
| | | } |
| | | throw e; |
| | |
| | | if (poolRegistered.compareAndSet(true, false)) { |
| | | final String registered=poolConnectionString; |
| | | poolConnectionString=null; |
| | | standingReadBound=STANDING_READ_BOUND_UNREAD; // it follows poolKey(), which a re-open may register elsewhere |
| | | if (registered!=null) { |
| | | CachedConnection.closePool(registered); |
| | | } |
| | |
| | | // that it is not reissued for every tree on every open; disabling and re-enabling the |
| | | // backend is the way to try again once the privilege has been granted |
| | | unstampableTrees.clear(); |
| | | // what this storage knows of its catalog holds no longer than the open it learnt it in: the |
| | | // table may well be gone by the next one, dropped by an offline tool run in the meantime |
| | | catalogTableOpened=false; |
| | | enrolledTrees.clear(); |
| | | // A closed backend has no use for its connections. They used to stay open - close() only |
| | | // flipped the status - so disabling or removing a JDBC backend left them behind, and with |
| | | // nothing left to expire the pool entry they could stay open for good (issue #878). |
| | | releasePool(); |
| | | } |
| | | |
| | | // The trees this storage has taken an interest in, and the tables they map to. listTrees() - |
| | | // and through it removeStorageFiles() - reads this, so a tree only belongs here once this |
| | | // backend uses it: see toTableName() below for the trees that are merely asked about. |
| | | // The trees this storage has taken an interest in, and the tables they map to: a memo, so that |
| | | // naming the table of a tree costs a map lookup rather than a digest. What a backend owns is |
| | | // recorded in its catalog and not here (#888) - listTrees() and removeStorageFiles() read that |
| | | // - but the distinction the two names below draw is kept all the same: a tree merely asked |
| | | // about is not one this storage has taken an interest in, and it stays out of the memo. |
| | | final LoadingCache<TreeName,String> tree2table = Caffeine.newBuilder() |
| | | .build(JDBCStorage::toTableName); |
| | | |
| | | /** |
| | | * The table a tree name maps to. A pure function of the name, so that a tree can be read |
| | | * without being entered into tree2table: the compressed schema reads the tree its definitions |
| | | * used to be shared under (#873), a tree this backend does not own, and removeStorageFiles() |
| | | * drops every table tree2table names. |
| | | * The table a tree name maps to. A pure function of the name, so that a tree can be read without |
| | | * being entered into tree2table: the compressed schema reads the tree its definitions used to be |
| | | * shared under (#873), which is a tree this backend does not own. |
| | | * <p> |
| | | * Which of the two a statement takes therefore says who owns the tree it names: a path that |
| | | * creates or writes one - openTree(), clearTree(), deleteTree(), put(), update(), delete() - |
| | | * takes the enrolling {@link #getTableName(TreeName)}, and a read-only path - read(), |
| | | * getRecordCount(), isExistsTable() and the cursor - takes {@link #readTableName(TreeName)}, |
| | | * which computes this only for a tree that is not enrolled already. Every tree this backend |
| | | * owns passes through openTree(name, true) as it is opened, so listTrees() still names the |
| | | * complete owned set. |
| | | * Which of the two names a statement takes therefore says whether this backend is claiming the |
| | | * tree it names: a path that creates or writes one - openTree(), clearTree(), deleteTree(), put(), |
| | | * update(), delete() - takes the enrolling {@link #getTableName(TreeName)}, and a path that only |
| | | * asks - read(), getRecordCount(), isExistsTable(), the cursor, and the read of what the catalog |
| | | * records - takes {@link #readTableName(TreeName)}, which computes this only for a tree that is not |
| | | * enrolled already. What a clear may drop is decided by the catalog of the backend (#888) and no |
| | | * longer by this memo, so an entry of it puts no table up for removal; the two names are what keeps |
| | | * the memo an account of the trees this backend claims all the same. |
| | | */ |
| | | static String toTableName(TreeName treeName) { |
| | | try { |
| | |
| | | } |
| | | |
| | | /** |
| | | * The pseudo base DN of the tree naming the trees of a backend. Every real tree of a backend is |
| | | * named after an entry container, whose prefix is a normalized DN and so always holds a "=", |
| | | * which an identifier of this form cannot collide with. |
| | | */ |
| | | static final String CATALOG_BASE_DN="opendj_catalog"; |
| | | |
| | | /** |
| | | * The base DN the compressed schema trees were named under before #881 gave each backend a pair |
| | | * of its own. It carries no backend qualifier, so on a database addressed by several backends - |
| | | * which nothing forbids (#873) - that pair of trees is the same pair for all of them, and a |
| | | * backend must not put a tree another one may be the owner of up for removal. It is the pair |
| | | * {@code PersistentCompressedSchema} migrates from and never writes to again, and it is left |
| | | * exactly where it lies: the definitions of a backend that has not been started since the |
| | | * upgrade are still in it. The pair each backend owns is named after its backend id, is under no |
| | | * such literal, and is enrolled like any other tree. |
| | | */ |
| | | static final String SHARED_COMPRESSED_SCHEMA_BASE_DN="compressed_schema"; |
| | | |
| | | /** |
| | | * The pair named under {@link #SHARED_COMPRESSED_SCHEMA_BASE_DN}, spelled out here because the |
| | | * names are private to {@code PersistentCompressedSchema} - where they are the LEGACY_ pair of |
| | | * #881. They are never enrolled, so nothing but this constant can name them - and a tool asking |
| | | * a backend what trees it holds has to be told about them all the same, which is what {@link |
| | | * #listTrees()} uses this for. |
| | | */ |
| | | static final List<TreeName> SHARED_COMPRESSED_SCHEMA_TREES=Collections.unmodifiableList(Arrays.asList( |
| | | new TreeName(SHARED_COMPRESSED_SCHEMA_BASE_DN, "compressed_attributes"), |
| | | new TreeName(SHARED_COMPRESSED_SCHEMA_BASE_DN, "compressed_object_classes"))); |
| | | |
| | | /** |
| | | * The tree naming the trees this backend owns: one row per tree, the tree name as its key and the |
| | | * table holding that tree as its value. |
| | | * <p> |
| | | * A table is named after the hash of its tree name, so the catalog of a database can neither be |
| | | * filtered by a per-backend prefix nor read back into a {@link TreeName}. Without a record of its |
| | | * own a backend can therefore only name the trees this very process has already touched - which |
| | | * is precisely what {@link #removeStorageFiles()} cannot have, running as it does before the root |
| | | * container is open. In the offline {@code import-ldif} nothing has touched a tree at all, so |
| | | * {@code --clearBackend} used to clear nothing whatsoever (#888). |
| | | * <p> |
| | | * The catalog is per backend and named after the backend id alone: a process that has opened |
| | | * nothing can still find its table, and backends sharing one database URL - which nothing |
| | | * forbids (#873) - never name each other's trees. The id goes in escaped, for the reason {@link |
| | | * #escapedBackendId} states: a name that does not survive being read back is a table of this |
| | | * backend that its own clear cannot recognize. |
| | | */ |
| | | TreeName getCatalogTree() { |
| | | return new TreeName(CATALOG_BASE_DN, escapedBackendId()); |
| | | } |
| | | |
| | | /** |
| | | * Whether the table of the catalog was created, or found, by this storage. A tree is enrolled on |
| | | * every open - about 25 of them for a stock suffix - and asking the catalog whether the table is |
| | | * there would cost a metadata round trip per tree. |
| | | */ |
| | | private volatile boolean catalogTableOpened=false; |
| | | |
| | | /** |
| | | * Serializes the one step above: two transactions opening trees at the same time would otherwise |
| | | * both find the table of the catalog absent and both create it, the second failing the open it |
| | | * belongs to. Held across the lookup and the statement that answer it, and across nothing else. |
| | | */ |
| | | private final Object catalogLock=new Object(); |
| | | |
| | | /** |
| | | * The trees the catalog already records at the table this version would record them at, read |
| | | * from it when this storage first opens it and added to as it enrols. A tree named here needs no |
| | | * row written for it: the row would be the one that is already there, and writing one is a |
| | | * statement and a commit on a connection this backend then has to have opened - a stock suffix |
| | | * has about 25 trees, and every open after the first enrols none of them. |
| | | * <p> |
| | | * A row recording another table than {@link #getTableName} would give is not in here: what a |
| | | * removal drops is the table the row records, so a row of a version naming its tables otherwise |
| | | * has to be rewritten rather than trusted. Held no longer than the open it was read in, like |
| | | * {@link #catalogTableOpened}, and given up whenever the catalog itself is. |
| | | */ |
| | | private final Set<TreeName> enrolledTrees=ConcurrentHashMap.newKeySet(); |
| | | |
| | | /** |
| | | * The table a tree name maps to, for a statement that only reads it. Answered from the memo of |
| | | * {@link #getTableName(TreeName)} where the tree is in it, and computed without being put there |
| | | * otherwise. |
| | |
| | | */ |
| | | static String storedIdentifier(DatabaseMetaData metaData, String name) throws SQLException { |
| | | if (metaData.storesUpperCaseIdentifiers()) { |
| | | return name.toUpperCase(); |
| | | return name.toUpperCase(Locale.ROOT); |
| | | } |
| | | if (metaData.storesLowerCaseIdentifiers()) { |
| | | return name.toLowerCase(); |
| | | return name.toLowerCase(Locale.ROOT); |
| | | } |
| | | return name; |
| | | } |
| | |
| | | boolean isMysqlBackslashEscape(Connection con) throws SQLException { |
| | | try (final PreparedStatement statement=con.prepareStatement("select @@sql_mode")) { |
| | | final String sqlMode=executeResultSet(statement, rs -> rs.next() ? rs.getString(1) : null); |
| | | return sqlMode==null || !sqlMode.toUpperCase().contains("NO_BACKSLASH_ESCAPES"); |
| | | return sqlMode==null || !sqlMode.toUpperCase(Locale.ROOT).contains("NO_BACKSLASH_ESCAPES"); |
| | | } |
| | | } |
| | | |
| | |
| | | return con; |
| | | } |
| | | |
| | | /** |
| | | * A connection of its own for the catalog of a backend, outside the pool for the reason a stamp |
| | | * connection is: the caller of openTree() is inside a transaction and holding a pooled connection |
| | | * already, and a pool that cannot open a second one waits for a peer to return one - which here is |
| | | * the very thread that is waiting. |
| | | * <p> |
| | | * It is established the way a pooled connection is and not the way a stamp connection is: the |
| | | * bounds of {@link CachedConnection.ConnectDialect} rather than of {@link Dialect}, so that a |
| | | * login which never answers is bounded, a bound the administrator set in the connection string is |
| | | * left exactly as they set it, and the read bound of the login is lifted as soon as the login is |
| | | * through (#872). A stamp is a diagnostic aid and gives up rather than queue behind another |
| | | * session; a catalog row is the state a clear reads, and it waits for its lock rather than dying |
| | | * on a read bound. The isolation is the pool's for the same reason: this connection issues the |
| | | * ordinary DML of this class, and the repeatable read a mysql server defaults to gap-locks a |
| | | * catalog two transactions enrol into. |
| | | * <p> |
| | | * The bound of the connect is the one the pool bounds its own connects by, read from {@link |
| | | * CachedConnection#CONNECT_TIMEOUT_PROPERTY} where an operator set it: a login of this database |
| | | * takes what it takes whoever is asking, so a deployment which had to raise that property must not |
| | | * meet a bound of this code's own here - a connect failing where the pooled one beside it succeeds |
| | | * is a backend that stops opening on an installation that opened before this connection existed. A |
| | | * property of 0 is the operator asking for no bound of the connect, and it is honoured here as it |
| | | * is by the pool. What does bound an attempt besides is the deadline of the retry below, which is |
| | | * the pool's own rule and applies to a borrow in exactly the same way; it is no bound of this |
| | | * code's own choosing. |
| | | * <p> |
| | | * The deadline of the whole thing is the pool's as well, {@link |
| | | * CachedConnection#POOL_TIMEOUT_PROPERTY}: a database that takes no connection <em>for the |
| | | * moment</em> - at its connection limit with one of ours on its way back to the pool, or still |
| | | * recovering - is waited out here exactly as a borrow waits it out, by the predicate the pool |
| | | * decides that by ({@link CachedConnection#isWorthRetrying}) and with the same backoff. Without |
| | | * it this connect makes one attempt where the borrow beside it makes many, and loses a race the |
| | | * pooled connection of the very same operation wins. Everything else - a password that is not |
| | | * accepted, a database that is down, a driver that is not on the classpath - is reported to the |
| | | * caller rather than retried behind its back. |
| | | * <p> |
| | | * What this deadline is not is the deque of the pool: the caller of {@code openTree()} is holding |
| | | * a pooled connection already, so waiting for a peer to return one would be waiting for the very |
| | | * thread that is waiting. It is the pool's retry that is wanted here and not its queue, which is |
| | | * why the loop below is its own rather than a borrow of {@link CachedConnection#getConnection}. |
| | | * What one attempt is, is the login and the set-up behind it, exactly as an attempt of a borrow is |
| | | * ({@code CachedConnection.connect}): a session the server takes and then kills off answers the |
| | | * first statement of the set-up rather than the login, and it is the same refusal either way. |
| | | * <p> |
| | | * That is also what this wait is weaker than a borrow at, and it is worth writing down rather than |
| | | * leaving to be discovered: a borrow can be answered by a peer handing a connection back, while |
| | | * nothing here can be answered by anything but a new login. Against a server at its connection |
| | | * limit whose remaining slots this backend's own pool is holding idle, the borrows of that pool |
| | | * clear and this does not - it waits out the deadline and reports the refusal. The deadline is |
| | | * therefore what bounds it, and a deployment which has set both properties to 0 has asked for a |
| | | * wait with no end to it here as much as in the pool. |
| | | * <p> |
| | | * One attempt is bounded by the configured connect timeout and by what is left of that deadline, |
| | | * whichever is the shorter, exactly as an attempt of a borrow is: an attempt left to run its own |
| | | * bound out past the deadline would overrun it by a whole connect timeout, and turning the |
| | | * per-attempt bound off must not turn the deadline off with it. So the connect property of 0 that |
| | | * the round before this one made honoured is the operator asking for no bound <em>of their own</em> |
| | | * here as it is in the pool, and what is left unbounded by both properties at 0 is left unbounded |
| | | * here too - a login the database accepts and never finishes then parks the transaction that asked |
| | | * for it. A borrow of the pool parks in exactly the same way, on exactly the same pair of settings. |
| | | * It does not park the rest of this storage: {@code openCatalog()} establishes this connection |
| | | * before it takes its lock, for that very reason. |
| | | * <p> |
| | | * What every wait here does hold is the caller: this runs inside the write transaction that reached |
| | | * {@code openTree}, so a retry that waits out a database refusing connections holds that |
| | | * transaction's pooled connection, the permit of the pool that connection carries (#878) and every |
| | | * lock the transaction has already taken, for as long as it waits. On a stock suffix {@code |
| | | * RootContainer.open()} is one such write over every tree of the backend. |
| | | * <p> |
| | | * And what it spends besides is the window {@link #write} bounds its own replay by, which is the |
| | | * shorter of the two by default - ten seconds against a minute - and is <em>spent</em> by this wait |
| | | * rather than added to it: this loop runs inside one attempt of that one. A refusal {@code write()} |
| | | * would replay - mysql answers its connection limit with {@code 08004}, postgres reports a database |
| | | * still coming up as {@code 57P03}, and both are read as a connection this backend lost - waited out |
| | | * here for a minute reaches that loop with its window six times over, so it is thrown unreplayed: |
| | | * the retry would have cost the caller the very replay it had before there was any retry here at |
| | | * all. So the deadline is the shorter of {@link CachedConnection#POOL_TIMEOUT_PROPERTY} and what is |
| | | * left of that window, taken from the caller by {@link CatalogSession#boundedAlsoBy}. The window is |
| | | * not something the property could express: lowering it under ten seconds shortens every borrow of |
| | | * the pool with it. A path carrying no such window - the importer, which has no replay above it - |
| | | * waits the property out in full, and a deployment which cannot afford a minute of that sets the |
| | | * property to what it can afford, the same property bounding the same wait as it bounds a borrow. |
| | | * <p> |
| | | * What the window does <em>not</em> bound is one attempt, which is taken from the deadline of the |
| | | * pool as it always was. The two are different questions: the window says how long it is worth |
| | | * waiting before handing the failure to a loop that can still replay it, while a login takes what |
| | | * this database takes whoever is asking. Cut to what is left of a ten second window, a deployment |
| | | * that raised {@link CachedConnection#CONNECT_TIMEOUT_PROPERTY} to two minutes because its login |
| | | * needs them would meet a catalog connect failing where the pooled connection beside it succeeds - |
| | | * the backend that stops opening. So one slow attempt may outlast the window, exactly as one slow |
| | | * conflict outlasts it in {@link #write} itself; what may not is a second attempt begun after the |
| | | * window has already run out, which is a wait bought with a replay that no longer exists. |
| | | * |
| | | * @param budgetDeadline the moment the replay window of the caller runs out, as {@link |
| | | * System#currentTimeMillis()} reads it, or {@link Long#MAX_VALUE} where nothing above this |
| | | * connect replays - the same "no deadline at all" this class reads out of {@link |
| | | * CachedConnection#deadlineOf}, so that the shorter of the two is a plain {@code min}. |
| | | */ |
| | | Connection newCatalogConnection(long budgetDeadline) throws SQLException { |
| | | // poolKey() rather than the configuration as it stands, for the reason newStampConnection() |
| | | // gives: this connection is not pooled, but it is a connection to the database of this |
| | | // storage, and db-directory may be changed on a running backend. Reading it again here would |
| | | // write the catalog of this backend into whichever database the configuration names now, |
| | | // while its tables are created, read and dropped over the connection open() registered - |
| | | // rows in one database and tables in another, which is #888 again by another route (#878) |
| | | final String connectionString=poolKey(); |
| | | final CachedConnection.ConnectDialect dialect=CachedConnection.ConnectDialect.of(connectionString); |
| | | final long connectTimeoutSeconds=CachedConnection.getConnectTimeoutSeconds(); |
| | | final long poolTimeoutSeconds=CachedConnection.getPoolTimeoutSeconds(); |
| | | final long startedAt=System.currentTimeMillis(); |
| | | final long poolDeadline=CachedConnection.deadlineOf(startedAt, poolTimeoutSeconds); |
| | | // the deadline of the whole wait, which is the shorter of the pool's own and what is left of |
| | | // the replay window of the caller. The bound of one attempt below is taken from the pool's |
| | | // alone, deliberately: a login the operator bounded at two minutes because that is what this |
| | | // database takes must not be cut to what is left of a ten second window - that is the connect |
| | | // dying where the pooled one beside it succeeds, which is a backend that stops opening. One |
| | | // slow attempt may still outlast the window, exactly as one slow conflict does; what may not |
| | | // is a second attempt begun after the window has run out, which is a wait for nothing |
| | | final long deadline=Math.min(poolDeadline, budgetDeadline); |
| | | long backoffMs=0; |
| | | int attempts=0; |
| | | while (true) { |
| | | attempts++; |
| | | try { |
| | | // the bound of one attempt and not of the whole wait, the way the pool bounds its own: |
| | | // an attempt left to run its bound out past the deadline would overrun it by a full |
| | | // connect timeout, and turning the per-attempt bound off must not turn this one off. |
| | | // Taken from the deadline of the pool and not from the shorter of the two: see above |
| | | return connectCatalog(connectionString, dialect, |
| | | CachedConnection.attemptSeconds(connectTimeoutSeconds, poolDeadline)); |
| | | }catch (SQLException e) { |
| | | if (!CachedConnection.isWorthRetrying(e, dialect)) { |
| | | // redacted the way the pool redacts the failure of its own connects: a driver renders |
| | | // the connection string it could not use into its message as readily as not, and the |
| | | // connection string of this backend carries the password of the account it works as. |
| | | // This failure is reported in full - ERR_OPEN_ENV_FAIL, or the log of a clear |
| | | throw CachedConnection.reported(e, connectionString); |
| | | } |
| | | final long now=System.currentTimeMillis(); |
| | | final long remaining=deadline-now; |
| | | if (remaining<=0) { |
| | | // which of the two bounds ended it, so that an operator reading the line knows whether |
| | | // the property is the thing to raise: where the replay window of the caller is the |
| | | // shorter one, raising the property moves nothing |
| | | throw catalogConnectTimedOut(connectionString, poolTimeoutSeconds, |
| | | deadline==budgetDeadline, now-startedAt, attempts, e); |
| | | } |
| | | CachedConnection.warnStallOutsidePool(connectionString, "tree catalog", attempts, startedAt, e); |
| | | backoffMs=Math.min(backoffMs==0 ? 1 : backoffMs*2, CachedConnection.MAX_BACKOFF_MS); |
| | | try { |
| | | Thread.sleep(Math.min(backoffMs, remaining)); |
| | | }catch (InterruptedException interrupted) { |
| | | // the flag is put back - Thread.sleep() clears it, and every frame above this one reads |
| | | // it to decide whether to unwind - and the wait is over: whoever asked this thread to |
| | | // stop is not answered by going on to sleep out the rest of a pool timeout. The driver |
| | | // failure is what this reports, it being the reason there was anything to wait for, and |
| | | // the interrupt is carried on it as suppressed so that a connect cut short by a |
| | | // shutdown is not read off the log as a database that would not take a connection |
| | | Thread.currentThread().interrupt(); |
| | | final SQLException reported=CachedConnection.reported(e, connectionString); |
| | | reported.addSuppressed(interrupted); |
| | | throw reported; |
| | | } |
| | | }catch (RuntimeException e) { |
| | | // a driver reporting a connect it will not make as an unchecked failure names the |
| | | // connection string just as readily, and it is not one of the two states a retry waits |
| | | // out: reported and handed on, exactly as the pool hands its own on. reportedUnchecked() |
| | | // answers with the original where it holds no credential, so nothing of a plain |
| | | // programming error is hidden by this |
| | | final Exception reported=CachedConnection.reportedUnchecked(e, connectionString); |
| | | if (reported instanceof SQLException) { // redacted, and reported as the connect failure it is |
| | | throw (SQLException) reported; |
| | | } |
| | | throw (RuntimeException) reported; // the original: it holds no credential of this backend |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The failure of a catalog connect that was worth retrying and ran the deadline out: a timeout by |
| | | * type, so that a caller can tell it from the first refusal, and carrying the state and the vendor |
| | | * code of the last failure of the driver rather than one of its own. |
| | | * <p> |
| | | * Not the {@code 08001} the pool answers a borrow of this shape with, and the difference is not |
| | | * cosmetic: this failure is raised inside {@link #write}, whose classification reads every state |
| | | * of class {@code 08} as a connection the database dropped ({@link #saysTheConnectionIsGone}). A |
| | | * manufactured one would put an attempt whose pooled connection is perfectly healthy into the |
| | | * replay and call {@link #distrustPool} on it over a database that had simply refused a new |
| | | * connection. |
| | | * <p> |
| | | * It buys exactly that and no more, which is worth being precise about: where the driver's own |
| | | * refusal is of class {@code 08} - mysql answers its connection limit with {@code 08004} - the |
| | | * attempt is classified as a dropped connection whatever this method does, the original being the |
| | | * cause of this one and every chain of a failure being walked. What this keeps is the promise that |
| | | * the retry changes no classification: a refusal reaches {@code write()} as the same thing it |
| | | * reached it as before there was any retry here at all. |
| | | * <p> |
| | | * Which of the two bounds ended the wait is named rather than left to be guessed: the property is |
| | | * the thing to raise only where the property is what ran out, and where the replay window of the |
| | | * caller is the shorter one - the default has it at a sixth of the property - raising the property |
| | | * moves nothing at all. |
| | | */ |
| | | private static SQLTimeoutException catalogConnectTimedOut(String connectionString, long poolTimeoutSeconds, |
| | | boolean endedByReplayWindow, long waitedMs, int attempts, SQLException last) { |
| | | final SQLTimeoutException timeout=new SQLTimeoutException("no connection to " |
| | | +CachedConnection.safeUrl(connectionString)+" could be opened for the tree catalog within " |
| | | +waitedMs+"ms ("+attempts+" attempts, "+(endedByReplayWindow |
| | | ? "what was left of the replay window of the write that asked for it, which is the shorter" |
| | | +" bound here: "+CachedConnection.POOL_TIMEOUT_PROPERTY+" is "+poolTimeoutSeconds+"s" |
| | | : CachedConnection.POOL_TIMEOUT_PROPERTY+"="+poolTimeoutSeconds+"s") |
| | | +"): the database took no connection for the moment," |
| | | +" last error: "+CachedConnection.redact(last.getMessage(), connectionString), |
| | | last.getSQLState(), last.getErrorCode()); |
| | | timeout.initCause(CachedConnection.reported(last, connectionString)); |
| | | return timeout; |
| | | } |
| | | |
| | | /** |
| | | * One attempt of {@link #newCatalogConnection}, established and set up or left holding nothing. |
| | | * Failures leave here as the driver reported them, checked and unchecked alike: what a retry is |
| | | * decided on is the chain of the original, and the redaction is the caller's - a redacted copy is |
| | | * rebuilt link by link, so redacting an attempt that is about to be retried would pay for a |
| | | * failure nobody ever sees. |
| | | */ |
| | | private Connection connectCatalog(String connectionString, CachedConnection.ConnectDialect dialect, |
| | | long timeoutSeconds) throws SQLException { |
| | | // A driver is free to write into the map it is handed, so every attempt gets one of its own. |
| | | final Properties properties=new Properties(); |
| | | final boolean readBoundSet=dialect!=null && timeoutSeconds>0 |
| | | && dialect.bound(connectionString, properties, timeoutSeconds); |
| | | final Connection con=DriverManager.getConnection(connectionString, properties); |
| | | try { |
| | | con.setAutoCommit(false); |
| | | con.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); |
| | | }catch (SQLException | RuntimeException e) { // nothing else holds this connection yet: it would leak |
| | | closeQuietly(con, e); |
| | | throw e; |
| | | } |
| | | if (readBoundSet) { |
| | | try { |
| | | // only where this code set one: a read bound of the connection string is the |
| | | // administrator's and is not lifted along with it, exactly as the pool leaves it |
| | | con.setNetworkTimeout(Runnable::run, 0); |
| | | }catch (SQLException | RuntimeException e) { |
| | | // A driver that will not take the bound back leaves it in force for the life of the |
| | | // connection, and that bound is the one this attempt was given - near the end of the |
| | | // deadline of the retry, a second. The connection is kept all the same, which is the |
| | | // pool's own answer to this failure: it stops pooling such a connection and still hands |
| | | // it to the borrower that is waiting. Failing here instead would stop the backend opening |
| | | // on a driver whose setNetworkTimeout is not implemented at all, where the pooled |
| | | // connection beside it works - and there is no state to fail with that write() does not |
| | | // read as a connection the database dropped. So it is reported, at the bound in force. |
| | | logger.warn(LocalizableMessage.raw("jdbc: the catalog connection of backend %s keeps the %ds read bound its login was given, so a statement of the catalog slower than that fails on it: %s", |
| | | config.getBackendId(), timeoutSeconds, stackTraceToSingleLineString(e))); |
| | | } |
| | | } |
| | | return con; |
| | | } |
| | | |
| | | /** Closes a connection nothing holds yet, reporting the failure of the close on the one being unwound. */ |
| | | private static void closeQuietly(Connection con, Throwable unwinding) { |
| | | try { |
| | | con.close(); |
| | | }catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well: this runs from the catch of a failure it must not replace |
| | | // (JLS 14.20.2), which is the rule every close of this class keeps |
| | | unwinding.addSuppressed(e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The connection the catalog table of a backend is read and written on, and the transaction over |
| | | * it. No other connection touches that table. |
| | | * <p> |
| | | * It belongs to a write transaction and not to the storage, and is opened at the first row that |
| | | * transaction has to write - so what costs a physical connect is a write which enrols a tree the |
| | | * catalog does not already record, and nothing else: a read-only storage opens none, a |
| | | * transaction that opens no tree opens none, and neither does one whose trees are all recorded |
| | | * already, which is every open after the first ({@link JDBCStorage#enrolledTrees} is the storage's and |
| | | * outlives them). The open of a backend is therefore one connect, and so is every later write |
| | | * that names a tree for the first time - {@code dsconfig create-backend-index} reaches exactly |
| | | * that, opening its new tree inside a write of its own on a running server. The connect is |
| | | * retried the way the pool retries its own for that reason; see {@link JDBCStorage#newCatalogConnection}. |
| | | * <p> |
| | | * Storage-scoped rather than transaction-scoped it cannot be: it is closed with the transaction |
| | | * because the rows it writes are the transaction's, and a connection outliving them would be a |
| | | * second pooled-connection lifetime for this class to get right. |
| | | * <p> |
| | | * Why the rows are not written on the caller's connection is in {@link |
| | | * WriteableTransactionTransactionImpl#enrolInCatalog}: they have to be committed, and that commit |
| | | * must not be the caller's. Why the read is not either is in {@link |
| | | * WriteableTransactionTransactionImpl#readEnrolledTrees}: a select of the caller's transaction |
| | | * would hold a lock on the catalog table for the whole life of that transaction, and the rows it |
| | | * decides are written from here. |
| | | */ |
| | | final class CatalogSession implements Closeable { |
| | | private Connection con; |
| | | private WriteableTransactionTransactionImpl txn; |
| | | |
| | | // The moment the replay window of the write() this session belongs to runs out, as |
| | | // System.nanoTime() reads it - null where nothing above this session replays, which is the |
| | | // importer and nothing else. Boxed rather than given a sentinel: nanoTime() is documented to |
| | | // return an arbitrary long, so there is no reading of it that could stand for "no window". |
| | | private Long replayWindowEndsAt; |
| | | |
| | | /** |
| | | * Tells this session the wall-clock window the {@link JDBCStorage#write} above it bounds its |
| | | * replay by, which the connect of the catalog may not outlast: the connect runs inside one |
| | | * attempt of that loop, so a wait longer than what is left of the window reaches it with the |
| | | * window already spent and is thrown unreplayed - see {@link JDBCStorage#newCatalogConnection}. |
| | | * Called once per attempt, before the operation runs; a session nobody calls it on waits the |
| | | * pool timeout out in full, which is what the importer does. |
| | | */ |
| | | void boundedAlsoBy(long replayWindowEndsAtNanos) { |
| | | replayWindowEndsAt=replayWindowEndsAtNanos; |
| | | } |
| | | |
| | | /** |
| | | * That window as a deadline of the clock the connect measures itself by, or {@link |
| | | * Long#MAX_VALUE} where there is no window - the value {@link CachedConnection#deadlineOf} |
| | | * gives a wait with no end, so that the shorter of the two is a plain {@code min}. The two |
| | | * readings are taken here rather than one of them being carried in: a nanoTime window and a |
| | | * currentTimeMillis deadline are two clocks, and they can only be put together at one moment. |
| | | * A window already spent gives the moment itself, which is one attempt and then a timeout. |
| | | */ |
| | | private long budgetDeadline() { |
| | | if (replayWindowEndsAt==null) { |
| | | return Long.MAX_VALUE; |
| | | } |
| | | final long leftNanos=replayWindowEndsAt-System.nanoTime(); |
| | | final long now=System.currentTimeMillis(); |
| | | return leftNanos<=0 ? now : now+leftNanos/1_000_000L; |
| | | } |
| | | |
| | | /** The connection, opened at the first read or write the catalog needs and shared by the rest. */ |
| | | Connection connection() throws SQLException { |
| | | if (con==null) { |
| | | con=newCatalogConnection(budgetDeadline()); |
| | | } |
| | | return con; |
| | | } |
| | | |
| | | /** Whether this session is holding a connection already, so that a caller knows what it made. */ |
| | | boolean isEstablished() { |
| | | return con!=null; |
| | | } |
| | | |
| | | /** |
| | | * A transaction over that connection, for its row statements alone: an upsert and a delete are |
| | | * per engine, and writing the catalog through the very ones every other tree is written through |
| | | * is what keeps its rows the same shape as theirs. It opens no tree and stamps no table, so the |
| | | * sessions it carries of its own are never opened. |
| | | */ |
| | | WriteableTransactionTransactionImpl transaction() throws SQLException { |
| | | final Connection con=connection(); |
| | | if (txn==null) { |
| | | txn=new WriteableTransactionTransactionImpl(con); |
| | | } |
| | | return txn; |
| | | } |
| | | |
| | | void commit() throws SQLException { |
| | | con.commit(); |
| | | } |
| | | |
| | | /** |
| | | * What a failed statement left behind must not poison the write of the next row: postgres |
| | | * refuses every further statement of a transaction whose statement failed (25P02) until it is |
| | | * rolled back, and this connection outlives the row that failed on it. |
| | | */ |
| | | void reset() { |
| | | if (con!=null) { |
| | | try { |
| | | con.rollback(); |
| | | }catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well: a driver is free to answer a rollback on a connection the |
| | | // database dropped with one, and this runs from the catch of a failure it must not |
| | | // replace - the caller goes on to report that failure, and in createCatalogTable() to |
| | | // tolerate a table another session created while this one was creating it |
| | | close(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The unchecked failure of a close is taken like the checked one, and the session is given up |
| | | * in a finally: this is called from {@link #reset}, which runs from the catch of a failure it |
| | | * must not replace (JLS 14.20.2) - {@code createCatalogTable()} goes on from there to tolerate |
| | | * a table another session created while this one was creating it. A session whose connection |
| | | * would not close is left holding none rather than holding a dead one. |
| | | * <p> |
| | | * The catch of {@code write()}'s own finally is the same guard one layer out, kept as the |
| | | * belt to this one's braces: it was the only guard while this method let an unchecked failure |
| | | * past, and a session that stops swallowing must not have to be found through a failure it |
| | | * replaced. |
| | | */ |
| | | @Override |
| | | public void close() { |
| | | if (con!=null) { |
| | | try { |
| | | con.close(); |
| | | }catch (SQLException | RuntimeException e) { |
| | | logger.trace(LocalizableMessage.raw("jdbc: unable to close the catalog connection: %s", stackTraceToSingleLineString(e))); |
| | | }finally { |
| | | con=null; |
| | | txn=null; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // The connection the comment statements of one sweep of openTree() calls share. Opening a |
| | | // backend opens every tree it holds (about 25 for a stock suffix), so a connection per stamp |
| | | // would mean that many physical connects on the first open after an upgrade - the one open |
| | |
| | | if (con!=null) { |
| | | try { |
| | | con.rollback(); |
| | | }catch (SQLException e) { |
| | | }catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well: a driver is free to answer a rollback on a connection the |
| | | // database dropped with one, and this runs from the catch of a failure it must not |
| | | // replace - the caller goes on to report that failure, and in createCatalogTable() to |
| | | // tolerate a table another session created while this one was creating it |
| | | close(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The unchecked failure of a close is taken like the checked one, and the session is given up |
| | | * in a finally, for the reason {@link CatalogSession#close} gives: this runs from {@link |
| | | * #reset}, which runs from the catch of a failure it must not replace - and a stamp that |
| | | * failed must never become the outcome of the open it was issued from. |
| | | */ |
| | | @Override |
| | | public void close() { |
| | | if (con!=null) { |
| | | try { |
| | | con.close(); |
| | | }catch (SQLException e) { |
| | | }catch (SQLException | RuntimeException e) { |
| | | logger.trace(LocalizableMessage.raw("jdbc: unable to close the comment connection: %s", stackTraceToSingleLineString(e))); |
| | | }finally { |
| | | con=null; |
| | | } |
| | | con=null; |
| | | } |
| | | mysqlBackslashEscape=null; // it described the session that has just gone |
| | | } |
| | |
| | | } |
| | | |
| | | // Returns the comment currently stored on the table, or null when there is none. The dialect is |
| | | // passed in rather than read off the connection: this runs on the stamp connection, which is |
| | | // not a pooled one, and only for the dialects commentTable() recognizes. |
| | | // passed in rather than read off the connection: the stamp sweep runs this on a connection of its |
| | | // own, a clear runs it on the pooled one it did its work on, and both only for the dialects |
| | | // commentTable() recognizes. It is a read and nothing else, and CachedConnection.close() rolls |
| | | // back before the connection is handed on, so a clear leaves no transaction of its own behind. |
| | | String readStoredComment(Connection con, Dialect dialect, String tableName) throws SQLException { |
| | | final String sql; |
| | | final String arg; |
| | |
| | | break; |
| | | case ORACLE: |
| | | sql="select comments from user_tab_comments where table_name=?"; |
| | | arg=tableName.toUpperCase(); |
| | | arg=tableName.toUpperCase(Locale.ROOT); |
| | | break; |
| | | case MICROSOFT: |
| | | sql="select cast(value as nvarchar(4000)) from sys.extended_properties where class=1 and major_id=object_id(?) and minor_id=0 and name='MS_Description'"; |
| | |
| | | static final String STATISTICS_TIMEOUT_PROPERTY=STATISTICS_PROPERTY+".timeout"; |
| | | private static final int STATISTICS_TIMEOUT_SECONDS_DEFAULT=600; |
| | | |
| | | /** |
| | | * What the statistics refresh may take, as configured. Read where the refresh runs and again |
| | | * where a standing read bound is weighed against the statements of this backend |
| | | * ({@link #loosestStatementBound()}): it is the loosest bound any statement here is given by |
| | | * default, so a standing bound under it cuts the refresh short of the very property that was |
| | | * meant to bound it. |
| | | */ |
| | | static int statisticsTimeoutSeconds() { |
| | | return clampSeconds(Integer.getInteger(STATISTICS_TIMEOUT_PROPERTY,STATISTICS_TIMEOUT_SECONDS_DEFAULT)); |
| | | } |
| | | |
| | | // A bulk load leaves the optimizer statistics of freshly created tables stale (a table that |
| | | // was never analyzed can make the planner badly misestimate the "where k>? order by k" cursor |
| | | // batches - see OpenIdentityPlatform/OpenDJ#859), so refresh them once the data is in place. |
| | |
| | | if (dialect==null) { // no portable statistics refresh for other engines |
| | | return false; // nothing was refreshed: reporting success here would make the assertion of the tests vacuous |
| | | } |
| | | final int timeoutSeconds=clampSeconds(Integer.getInteger(STATISTICS_TIMEOUT_PROPERTY,STATISTICS_TIMEOUT_SECONDS_DEFAULT)); |
| | | final int timeoutSeconds=statisticsTimeoutSeconds(); |
| | | boolean allRefreshed=true; |
| | | for (final TreeName treeName : trees) { |
| | | final String tableName=getTableName(treeName); |
| | |
| | | break; |
| | | case ORACLE: |
| | | sql="begin dbms_stats.gather_table_stats(user, ?); end;"; |
| | | args=new String[]{tableName.toUpperCase()}; |
| | | args=new String[]{tableName.toUpperCase(Locale.ROOT)}; |
| | | break; |
| | | case MICROSOFT: |
| | | sql="update statistics "+tableName; |
| | |
| | | return allRefreshed; |
| | | } |
| | | |
| | | /** |
| | | * Whether a table of this name is one the given connection reaches: in its database, and in one of |
| | | * the schemas an unqualified name of it resolves in - see {@link TableScope}, which is where the |
| | | * reason for each half of that question is. Asked of the catalog by name rather than by listing |
| | | * every table of the database: openTree(createOnDemand) asks it for every tree of the backend - |
| | | * about 25 of them for a stock suffix - on every open, on a database this backend may well be |
| | | * sharing with something else. |
| | | */ |
| | | boolean isExistsTable(Connection con, TableScope scope, String tableName) { |
| | | // bounded as the operation it is, not as the bulk statement it guards and not as the class of |
| | | // the transaction that happens to ask (#882): it reads a data dictionary rather than the data, |
| | | // so a wait here is the metadata lock of another session |
| | | try { |
| | | return bounded(con, StatementBound.OPERATION, () -> { |
| | | final DatabaseMetaData metaData = con.getMetaData(); |
| | | // asked with no schema pattern and read through the scope instead: what an unqualified |
| | | // statement reaches is a path of schemas and not one of them, and a pattern is no way to |
| | | // name a path - nor an exact way to name even one of it, "_" being a wildcard there |
| | | try (final ResultSet rs = metaData.getTables(scope.catalog, null, |
| | | storedIdentifier(metaData, tableName), new String[]{"TABLE"})) { |
| | | while (rs.next()) { |
| | | // the name still has to be compared: "_" is a single-character wildcard in a |
| | | // metadata pattern, so "opendj_<hash>" also matches a table named "opendjX<hash>" |
| | | if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME")) && scope.covers(rs)) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | return false; |
| | | }); |
| | | } catch (Exception e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void removeStorageFiles() throws StorageRuntimeException { |
| | | final boolean isOpen=getStorageStatus().isWorking(); |
| | |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | final Set<TreeName> trees=listTrees(); |
| | | if (!trees.isEmpty()) { |
| | | try (final Connection con = getValidatedConnection()) { |
| | | try { |
| | | for (final TreeName treeName : trees) { |
| | | try (final PreparedStatement statement = con.prepareStatement("drop table " + getTableName(treeName))) { |
| | | execute(statement, StatementBound.BULK); |
| | | try (final Connection con = getValidatedConnection()) { |
| | | // where an unqualified name of this connection resolves, which every lookup below is |
| | | // narrowed to: the skip in the loop decides between leaving a row where it is and dropping |
| | | // the table it names, and a table of that name in another database of the server must not |
| | | // be allowed to answer for this one - nor a table of this backend go unfound for living in |
| | | // another schema of the search path than the one the connection works in |
| | | final TableScope scope=TableScope.of(this, con); |
| | | // the catalog names what this backend owns, and only that: listTrees() also names the |
| | | // shared compressed schema trees, which another backend of this database may be the only |
| | | // owner of and which a clear must therefore leave exactly where they lie (#881) |
| | | final List<String> skippedRows=new ArrayList<>(); // rows the read could not act on: reported below |
| | | final Map<TreeName,String> trees=catalogTables(con, scope, skippedRows); |
| | | final TreeName catalogTree=getCatalogTree(); |
| | | int dropped=0; |
| | | // the same count with the catalog itself left out, which is what says whether this clear |
| | | // removed anything of the backend: a catalog table standing over rows that name nothing - |
| | | // a backup restored older than the tables it was taken beside - is dropped like any other |
| | | // and would otherwise make a clear that removed no tree at all look like a clear that did |
| | | // something. See reportClearOutcome() |
| | | int droppedTrees=0; |
| | | // counted without the catalog, for the reason droppedTrees is kept apart from dropped: the |
| | | // catalog is walked by this loop like any other table, so a catalog table that went between |
| | | // the lookup of catalogTables() and the loop's own would otherwise be summed up as a tree of |
| | | // this backend that had lost its table |
| | | int missingTrees=0; |
| | | try { |
| | | for (final Map.Entry<TreeName,String> tree : trees.entrySet()) { |
| | | final String tableName=tree.getValue(); |
| | | final boolean isCatalog=catalogTree.equals(tree.getKey()); |
| | | if (!isExistsTable(con, scope, tableName)) { // a row of the catalog outliving its table |
| | | reportClearLine(LocalizableMessage.raw( |
| | | "jdbc: backend %s names tree %s, whose table %s is not there: nothing to drop for it", |
| | | config.getBackendId(), tree.getKey(), tableName)); |
| | | if (!isCatalog) { |
| | | missingTrees++; |
| | | } |
| | | continue; |
| | | } |
| | | con.commit(); |
| | | } catch (SQLException e) { |
| | | dropTable(con, tableName); |
| | | dropped++; |
| | | if (!isCatalog) { |
| | | droppedTrees++; |
| | | } |
| | | } |
| | | con.commit(); |
| | | } catch (Exception e) { |
| | | // every failure of the loop and not the SQLException alone: the lookup deciding each |
| | | // drop answers with a StorageRuntimeException of its own, and a drop left pending by one |
| | | // of those has to go back here rather than wait for the connection to be handed back |
| | | try { |
| | | con.rollback(); |
| | | } catch (SQLException e2) {} |
| | | throw e instanceof StorageRuntimeException ? (StorageRuntimeException) e : new StorageRuntimeException(e); |
| | | } |
| | | // all tables are gone: a table recreated later deserves a fresh stamp attempt, and the |
| | | // memoized table name of a tree nothing holds any more is of no use to anyone |
| | | for (final TreeName treeName : trees.keySet()) { |
| | | tree2table.invalidate(treeName); |
| | | unstampableTrees.remove(treeName); |
| | | } |
| | | try { |
| | | reportClearOutcome(con, scope, dropped, droppedTrees, missingTrees, skippedRows); |
| | | } catch (RuntimeException e) { |
| | | // the clear itself is done and committed: an account of what it left standing must not be |
| | | // the thing that reports it as failed, and a caller retrying it would find nothing to drop |
| | | logger.trace(LocalizableMessage.raw("jdbc: unable to report what the clear left standing: %s", |
| | | stackTraceToSingleLineString(e))); |
| | | } |
| | | } catch (StorageRuntimeException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | throw new StorageRuntimeException(e); |
| | | } finally { |
| | | // the catalog went with the rest: the next tree enrolled creates its table again. The |
| | | // online import needs exactly that - the storage which has just dropped its tables is the |
| | | // one going on to open a root container and enrol every tree of it anew, which is what the |
| | | // forgotten enrolments make it do rather than skip as already recorded. |
| | | catalogTableOpened=false; |
| | | enrolledTrees.clear(); |
| | | if (!isOpen) { |
| | | close(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Drops one table of a clear. It is a method of its own so that the order {@link |
| | | * #removeStorageFiles()} drops in can be watched from a test: what names the trees has to outlive |
| | | * them, and that guarantee is the loop's - it holds because the loop walks the catalog's map in |
| | | * the order that map was built in, and a test asserting on the map instead would go on passing |
| | | * over a loop that had stopped doing so. |
| | | */ |
| | | void dropTable(Connection con, String tableName) throws SQLException { |
| | | try (final PreparedStatement statement = con.prepareStatement("drop table " + tableName)) { |
| | | // bulk, as #882 made every drop of this backend: nobody waits on a clear, and what it takes |
| | | // follows the size of the table rather than the work of a caller |
| | | execute(statement, StatementBound.BULK); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Where a line of the account a clear gives of itself goes: the logger, and nothing else in |
| | | * production. It is a method of its own so that a case can hold that account to what it says - |
| | | * these lines change no state at all, so every assertion a clear can be given about the database |
| | | * passes just as well with all of them deleted, and this report has now been changed in three |
| | | * rounds of review with nothing able to fail. See {@code TestCase.ReportingStorage}, which |
| | | * collects them. |
| | | */ |
| | | void reportClearLine(LocalizableMessage line) { |
| | | logger.warn(line); |
| | | } |
| | | |
| | | /** |
| | | * Reports what a clear did not remove, once everything the catalog named is gone. |
| | | * <p> |
| | | * An "opendj" table still standing at that point is named by no catalog of this backend, and its |
| | | * name says nothing about whose it is - a table is named after the hash of its tree name. What |
| | | * does say so is the comment a table is stamped with as it is opened (#866): the tree name in |
| | | * plain text. A table whose stamp names a tree of a base DN this backend does not serve belongs to |
| | | * a backend sharing this database (#873) and is passed over in silence; one whose stamp names a |
| | | * tree of this backend is reported as its own, and so as removable by hand; one carrying no stamp |
| | | * at all - left by a version stamping no table, or by a database that refused the comment - can be |
| | | * attributed to nobody and is reported as exactly that. A stamp the database would not give up is |
| | | * reported apart from all of these: it says nothing either way, and counting it as a table without |
| | | * a stamp would turn a connection that died halfway into a confident line about tables this |
| | | * backend may well own. |
| | | * <p> |
| | | * The silence has a cost worth stating: a table stamped with a tree of a base DN that was taken |
| | | * out of the configuration while the backend was disabled reads exactly like a table of a backend |
| | | * sharing the database, the stamp naming the tree and never the backend it belonged to, so it is |
| | | * passed over too. What is left of such a base DN is found by its stamp and removed by hand. |
| | | * <p> |
| | | * The shared compressed schema pair is left out of all of it: it is kept on purpose (#881), so it |
| | | * is no leftover of anything, and naming it here would be asking for the removal of the one thing |
| | | * this code goes out of its way to spare. |
| | | * <p> |
| | | * A clear which removed no tree of this backend is called out ahead of all of it: #888 was exactly |
| | | * such a clear, and it went by without a word in the log. A backend upgraded in place is the one |
| | | * case where a clear drops nothing while there is something to drop - nothing enrols a tree before |
| | | * {@link #removeStorageFiles()} runs, so the first offline clear of such a backend finds no |
| | | * catalog at all - and the line says so rather than leaving it to be found out. |
| | | * <p> |
| | | * The catalog table is no term of that count. It is dropped like any other and by the same loop, |
| | | * so a catalog standing over rows that name nothing - a backup restored older than the tables it |
| | | * was taken beside - is one table dropped and not one tree removed, and the line has to fire there |
| | | * too: what an operator meets in that case is the same clear that removed none of their data. |
| | | * <p> |
| | | * A database which would not say what is standing gets a line of its own, whatever the clear |
| | | * dropped. What was left behind is exactly what could not be found out there, so it is no more a |
| | | * clear that left nothing than one that left something, and the count of what it did drop is the |
| | | * only thing that can still be stated: reporting it through the line above would say "the clear |
| | | * dropped no table at all" of a clear that dropped a dozen. |
| | | * <p> |
| | | * A row of the catalog the read passed over is reported wherever the clear got to, that line |
| | | * depending on nothing this database was asked afterwards: what such a row records is outside the |
| | | * namespace {@link #leftoverTables} scans, so no other line here can name it. The row itself does |
| | | * not survive the clear - the catalog names itself last and the loop drops that table with every |
| | | * row still in it - which is why the line is the only surviving copy of what the row said, and |
| | | * why it names what the row recorded rather than telling an operator to go and look. Nothing this |
| | | * version writes makes such a row - {@link #getTableName} names every table {@code opendj_<hash>} |
| | | * - so it is the account of a database written into by something else. |
| | | * <p> |
| | | * It is a term of the "dropped nothing" line all the same, and for one state only: a catalog whose |
| | | * table is there names itself, so a clear reading any row at all normally drops that one and the |
| | | * term is carried by the drop count beside it. Where it is not is where the catalog table went |
| | | * between the read of its rows and the loop that drops them - another process clearing the same |
| | | * backend - and there the clear has read a row, dropped nothing, and has this row as the whole of |
| | | * what it can say. Without the term it says nothing at all, which is the silence of #888. |
| | | */ |
| | | void reportClearOutcome(Connection con, TableScope scope, int dropped, int droppedTrees, int missingTrees, |
| | | List<String> skippedRows) { |
| | | final ClearLeftovers leftovers=leftoverTables(con, scope); |
| | | if (leftovers==null) { |
| | | // a line of its own and not a clause of the one below: this says nothing about whether |
| | | // anything was left behind, so a clear that dropped its tables must not be reported here as |
| | | // one that dropped none - and one that dropped none must still say so, that silence being |
| | | // the whole of #888 |
| | | reportClearLine(LocalizableMessage.raw("jdbc: backend %s: %s (it dropped %d table(s) in all, its own catalog among them where there was one) and %d of the trees its catalog names had lost their table already; what else is standing could not be read off this database, so this clear says nothing about it.%s", |
| | | config.getBackendId(), removedTrees(droppedTrees), dropped, missingTrees, |
| | | droppedTrees==0 ? " "+CLEAR_DROPPED_NOTHING : "")); |
| | | reportSkippedRows(skippedRows); // read off the catalog and not off this database: still worth stating |
| | | return; |
| | | } |
| | | final int ours=leftovers.ours.size(); |
| | | final int unattributed=leftovers.unattributed.size(); |
| | | final int unreadable=leftovers.unreadable.size(); |
| | | // first of the lines, and not last: on a backend upgraded in place every table of it is |
| | | // unstamped and lands in the list below, and the operator has to be told why before being |
| | | // handed a list of tables their own backend is very probably still using. |
| | | // Decided on the drops of trees and not on every drop: the catalog table is dropped by the same |
| | | // loop, so a catalog standing over rows that name nothing makes "dropped" one while no tree of |
| | | // this backend was removed - which is the state this line exists to explain. |
| | | // Each tree which had lost its table is logged as the loop skips it; this line only sums them up. |
| | | // "there was something to act on" and not "something is still standing": a clear which dropped |
| | | // its own catalog and removed no tree of the backend is the #888 outcome exactly, and it says so |
| | | // whether or not the scan afterwards found anything to attribute. Without the two terms on the |
| | | // right the line is silent in that case while the same clear on a database whose listing failed |
| | | // announces itself - the same clear, told two ways. |
| | | // The last of them is not spare: a clear normally drops the catalog table it read its rows out |
| | | // of, so a passed-over row comes with a drop - except where that table went while this clear was |
| | | // running, which is a clear that read a row, dropped nothing, and has that row as all it can say |
| | | if (droppedTrees==0 && (missingTrees>0 || ours>0 || unattributed>0 || unreadable>0 |
| | | || dropped>0 || !skippedRows.isEmpty())) { |
| | | reportClearLine(LocalizableMessage.raw("jdbc: backend %s: %s (it dropped %d table(s) in all, its own catalog among them where there was one): %d of the trees its catalog names had lost their table already, and %d table(s) of this backend were named by no catalog, %d could not be attributed to anyone and %d could not be read. %s", |
| | | config.getBackendId(), removedTrees(droppedTrees), dropped, missingTrees, ours, unattributed, |
| | | unreadable, CLEAR_DROPPED_NOTHING)); |
| | | } |
| | | reportSkippedRows(skippedRows); // after the reason above and among the lists, being a list itself |
| | | if (ours>0) { |
| | | reportClearLine(LocalizableMessage.raw("jdbc: backend %s: %d table(s) of %s hold trees of this backend that its catalog does not name, and the clear left them where they are: %s. A tree is enrolled as it is opened read-write and by no other means, so such a table is one of a tree of a base DN this backend still serves that was taken out of the configuration while it was disabled - an attribute index, say - or one left by a version keeping no catalog: it is this backend's own and can be removed by hand, and re-adding the tree it belongs to adopts it with the rows it still holds", |
| | | config.getBackendId(), ours, scope.name(), leftovers.ours)); |
| | | } |
| | | if (unattributed>0) { |
| | | reportClearLine(LocalizableMessage.raw("jdbc: backend %s: %d opendj table(s) of %s are named by no catalog of this backend and carry no tree stamp, so nothing says whose they are: %s. They may hold the trees of a backend sharing this database, which nothing forbids, or be leftovers of a version stamping no table at all - a table is named after the hash of its tree name and can be attributed by no other means. They were left exactly where they are", |
| | | config.getBackendId(), unattributed, scope.name(), leftovers.unattributed)); |
| | | } |
| | | if (unreadable>0) { |
| | | reportClearLine(LocalizableMessage.raw("jdbc: backend %s: the stamp of %d opendj table(s) of %s could not be read, so this clear says nothing about whose they are: %s. They were left exactly where they are", |
| | | config.getBackendId(), unreadable, scope.name(), leftovers.unreadable)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * What a clear did to the trees of its backend, in the one wording both lines of the report use: |
| | | * a condition an operator greps for - and a case asserts on - must not be phrased one way where |
| | | * the leftover scan answered and another way where it did not. |
| | | */ |
| | | private static String removedTrees(int droppedTrees) { |
| | | return droppedTrees==0 ? "the clear removed no tree of this backend" |
| | | : "the clear removed "+droppedTrees+" tree(s) of this backend"; |
| | | } |
| | | |
| | | /** |
| | | * Reports the rows of the catalog the clear could not act on; see {@link #readCatalogRows} for |
| | | * what makes a row one of these and {@link #reportClearOutcome} for why they are a line of their |
| | | * own. Silent where there are none, which is every clear of a catalog this backend wrote. |
| | | * <p> |
| | | * The row is gone by the time this prints and what it recorded is not: the catalog names itself |
| | | * last, so the loop drops the table holding these rows along with every other - and where that |
| | | * table went on its own between the two lookups, it took them with it just the same. That is what |
| | | * the line has to say, and why it carries the recorded name rather than sending an operator to a |
| | | * table that is no longer there. |
| | | */ |
| | | private void reportSkippedRows(List<String> skippedRows) { |
| | | if (skippedRows.isEmpty()) { |
| | | return; |
| | | } |
| | | reportClearLine(LocalizableMessage.raw("jdbc: backend %s: %d row(s) of its catalog named nothing this clear could drop and were passed over: %s. The rows are gone with the catalog table, which a clear drops last; whatever they record was left standing, and no other line of this clear names it: the tables of this backend are named after the hash of a tree name, so what such a row records is outside the names a clear can account for. This line is the only surviving copy of it. A catalog holding such a row was written into by something other than this backend", |
| | | config.getBackendId(), skippedRows.size(), skippedRows)); |
| | | } |
| | | |
| | | /** |
| | | * Why a clear can remove no tree while there is something to remove, said wherever one did: it is |
| | | * the silence of #888, and the one thing an operator reading such a line has to be told. |
| | | */ |
| | | private static final String CLEAR_DROPPED_NOTHING="A backend upgraded from a version keeping no catalog has to be started once before its first offline \"import-ldif --clearBackend\": nothing enrols a tree before the clear runs, so that first clear finds a catalog that is not there - or, where the tables were restored from a backup taken beside an older one, a catalog that is there and names nothing - and removes no tree either way"; |
| | | |
| | | /** What a clear left standing, told apart by the tree stamp of each table; see {@link #reportClearOutcome}. */ |
| | | static final class ClearLeftovers { |
| | | /** Tables whose stamp names a tree of this backend: its own, and removable by hand. */ |
| | | final List<String> ours=new ArrayList<>(); |
| | | /** Tables carrying no stamp naming a tree: they can be attributed to nobody. */ |
| | | final List<String> unattributed=new ArrayList<>(); |
| | | /** Tables whose stamp the database would not give up: they are attributed neither way. */ |
| | | final List<String> unreadable=new ArrayList<>(); |
| | | } |
| | | |
| | | /** |
| | | * The "opendj" tables this connection reaches - see {@link TableScope} - that this backend can say |
| | | * something about, or {@code null} where the database would not list them. A table stamped with a |
| | | * tree this backend does not serve is in none of the lists: it is a backend sharing this database |
| | | * (#873) that it belongs to, and no part of this clear's outcome. |
| | | */ |
| | | ClearLeftovers leftoverTables(Connection con, TableScope scope) { |
| | | // the shared compressed schema pair is left standing on purpose, so it is no leftover of |
| | | // anything and reporting it would be pointing at the one thing this code goes out of its way |
| | | // to keep. Taken out by name and not by stamp: an installation may hold the pair unstamped, |
| | | // from a version that commented no table at all. |
| | | final Set<String> leftOnPurpose=new HashSet<>(); |
| | | for (final TreeName treeName : SHARED_COMPRESSED_SCHEMA_TREES) { |
| | | leftOnPurpose.add(readTableName(treeName).toLowerCase(Locale.ROOT)); |
| | | } |
| | | final ClearLeftovers leftovers=new ClearLeftovers(); |
| | | try { |
| | | // by name and not by row: the listing is asked with no schema pattern and read back through |
| | | // the resolution path (see TableScope), so two schemas of that path holding a table of the |
| | | // same name both answer here - and there is exactly one thing this report can say of that |
| | | // name, the stamp being read through an unqualified statement that resolves whichever of |
| | | // them the path reaches first. Named twice it would read as two leftovers where the clear |
| | | // can account for one. By the name exactly as the database spells it, and not folded: what |
| | | // this collapses is one name in two schemas, while two names differing only in case are |
| | | // two tables of a case-preserving engine and each is a leftover of its own |
| | | final Set<String> standing=new LinkedHashSet<>(); |
| | | final DatabaseMetaData metaData=con.getMetaData(); |
| | | try (final ResultSet rs=metaData.getTables(scope.catalog, null, |
| | | storedIdentifier(metaData, "opendj%"), new String[]{"TABLE"})) { |
| | | while (rs.next()) { |
| | | final String tableName=rs.getString("TABLE_NAME"); |
| | | if (tableName==null) { // a row naming no table names nothing this clear can report |
| | | continue; |
| | | } |
| | | if (!leftOnPurpose.contains(tableName.toLowerCase(Locale.ROOT)) && scope.covers(rs)) { |
| | | standing.add(tableName); |
| | | } |
| | | } |
| | | } |
| | | // the stamps are read once the metadata result set is closed: they are queries of this very |
| | | // connection, and a driver may hold it for the whole of that result set |
| | | final Dialect dialect=dialectOf(con); |
| | | if (dialect==null) { |
| | | // no comment readback is known for this engine, so no table of it can be attributed to |
| | | // anyone at all. That is a different thing from a table which carries no stamp, and saying |
| | | // the second would be telling an operator that every table of every backend of this |
| | | // database is of unknown ownership when the truth is that nothing was ever asked |
| | | leftovers.unreadable.addAll(standing); |
| | | return leftovers; |
| | | } |
| | | for (final String tableName : standing) { |
| | | final TreeName stamp; |
| | | try { |
| | | stamp=stampedTree(con, dialect, tableName); |
| | | } catch (SQLException | RuntimeException e) { |
| | | // this table alone is unaccounted for, and the ones after it need not be: postgres |
| | | // refuses every further statement of a transaction whose statement failed (25P02), so |
| | | // the read that failed is rolled back before the next table is asked about. There is |
| | | // nothing pending to lose - the clear committed its drops before this ran |
| | | logger.trace(LocalizableMessage.raw("jdbc: unable to read the stamp of table %s: %s", |
| | | tableName, stackTraceToSingleLineString(e))); |
| | | leftovers.unreadable.add(tableName); |
| | | try { |
| | | con.rollback(); |
| | | } catch (SQLException e2) {} |
| | | throw new StorageRuntimeException(e); |
| | | continue; |
| | | } |
| | | } catch (Exception e) { |
| | | throw new StorageRuntimeException(e); |
| | | if (stamp==null) { |
| | | leftovers.unattributed.add(tableName); |
| | | } else if (isOwnTree(stamp)) { |
| | | leftovers.ours.add(tableName+" ("+stamp+")"); |
| | | } |
| | | } |
| | | // all tables are gone: forget the mappings so listTrees() consumers skip the dropped trees |
| | | for (final TreeName treeName : trees) { |
| | | tree2table.invalidate(treeName); |
| | | unstampableTrees.remove(treeName); // a table recreated later deserves a fresh stamp attempt |
| | | } |
| | | } catch (SQLException e) { |
| | | logger.trace(LocalizableMessage.raw("jdbc: unable to look for the tables a clear left behind: %s", |
| | | stackTraceToSingleLineString(e))); |
| | | return null; |
| | | } |
| | | if (!isOpen) { |
| | | close(); |
| | | return leftovers; |
| | | } |
| | | |
| | | /** |
| | | * The tree named by the comment this table carries (#866), or {@code null} where it carries none |
| | | * or where what it carries is not the name of a tree. The stamp is the only thing that attributes |
| | | * a table to a backend at all - a table name is a bare hash - and stamping is best-effort, so the |
| | | * absence of one states nothing. |
| | | * <p> |
| | | * A read the database refused is passed to the caller rather than answered as an absent stamp: the |
| | | * two say different things, and the second would let a connection that died halfway be reported as |
| | | * a row of tables nothing can be said about. An engine with no readback of its own is the same |
| | | * distinction one step earlier, and is answered by the caller: it puts every table of such an |
| | | * engine where nothing was asked of it belongs, which is not where a table without a stamp goes. |
| | | */ |
| | | private TreeName stampedTree(Connection con, Dialect dialect, String tableName) throws SQLException { |
| | | final String comment=readStoredComment(con, dialect, tableName); |
| | | if (comment==null || comment.isEmpty()) { |
| | | return null; |
| | | } |
| | | try { |
| | | return TreeName.valueOf(comment); |
| | | } catch (RuntimeException e) { // a comment of somebody else's making: no stamp of this backend's kind |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * The base DN the compressed schema trees of this backend are named under since #881, spelled out |
| | | * here for the reason {@link #SHARED_COMPRESSED_SCHEMA_TREES} is: the prefix is built by a private |
| | | * method of {@code PersistentCompressedSchema}, escapes and all. A table stamped with one of these |
| | | * carries this backend's id in plain text, so a clear that finds one standing can say whose it is. |
| | | */ |
| | | private String ownCompressedSchemaBaseDN() { |
| | | return SHARED_COMPRESSED_SCHEMA_BASE_DN+"_"+escapedBackendId(); |
| | | } |
| | | |
| | | /** |
| | | * The backend id as one component of a tree name. A tree name is {@code /<base DN>/<id>} and is |
| | | * read back by splitting on its slashes ({@code TreeName.valueOf}), so an id carrying one of them |
| | | * would name a tree that parses into another tree than it was built from - and a table is stamped |
| | | * with that name (#866), so a clear reading the stamp of a table of this backend's own would then |
| | | * fail to recognize it and pass it over in silence. The escape is the one {@code |
| | | * PersistentCompressedSchema} spells its own prefix with, percent first so that the escape of the |
| | | * slash cannot be produced twice, and it leaves an id of the ordinary shape exactly as it is - |
| | | * which is what keeps the table names of an installation unchanged. |
| | | */ |
| | | private String escapedBackendId() { |
| | | return config.getBackendId().replace("%", "%25").replace("/", "%2F"); |
| | | } |
| | | |
| | | /** |
| | | * Whether this tree is one of this backend's own: a tree of a base DN it serves, its own catalog, |
| | | * or its own pair of compressed schema trees. The catalog counts because a clear drops it last, so |
| | | * one still standing is a clear of this backend that did not get to the end, and never anything of |
| | | * anybody else's. The compressed schema pair counts because since #881 it is named after the |
| | | * backend id (#873) and so belongs to this backend as plainly as any tree of a base DN it serves - |
| | | * where the legacy pair, named from a literal, belongs to no backend in particular and is reported |
| | | * by nobody. |
| | | */ |
| | | private boolean isOwnTree(TreeName treeName) { |
| | | if (getCatalogTree().equals(treeName) || ownCompressedSchemaBaseDN().equals(treeName.getBaseDN())) { |
| | | return true; |
| | | } |
| | | final SortedSet<DN> baseDNs=config.getBaseDN(); |
| | | if (baseDNs==null) { |
| | | return false; |
| | | } |
| | | for (final DN baseDN : baseDNs) { |
| | | // every tree of an entry container is named after the normalized form of its base DN, |
| | | // which is what EntryContainer builds its tree names from |
| | | if (treeName.getBaseDN().equals(baseDN.toNormalizedUrlSafeString())) { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * The database and the schemas a connection reaches with an unqualified name: what every table |
| | | * lookup of this backend is narrowed to. |
| | | * <p> |
| | | * The database is the half that has to narrow. Asked with a null catalog the question spans the |
| | | * whole server on some drivers - Connector/J reads a null catalog as "any database" since 8.0, and |
| | | * its databaseTerm being CATALOG it ignores the schema pattern besides - and every answer of such a |
| | | * lookup decides something a table of the same name in another database must have no say in. A |
| | | * clear skips the row of a table that is gone so that it can go on, and a foreign table answering |
| | | * for it turns that skip into an unqualified "drop table" of a table that is not in this database, |
| | | * failing the clear on this attempt and on every attempt after it. An open of a tree creates its |
| | | * table where there is none, and a foreign table answering for it skips the creation, leaving the |
| | | * catalog naming a tree whose table is not here. Two backends of the stock backend id in two |
| | | * databases of one server name their tables alike, so this is the ordinary layout and not a corner |
| | | * of one. |
| | | * <p> |
| | | * The schema is the half that must not narrow to one name. The statements this scope guards are |
| | | * unqualified, and an unqualified name resolves across a path of schemas: the whole |
| | | * {@code search_path} on postgresql, the default schema of the user and then {@code dbo} on sql |
| | | * server. A lookup narrowed to {@code current_schema()} alone would be the stricter question of the |
| | | * two - an installation whose tables were created in {@code public} while the connection now works |
| | | * in a schema of its own reads and writes them unqualified all the same, and asking only about that |
| | | * schema would report them absent: the clear would drop nothing, which is #888 over again, and the |
| | | * next open would create a second, empty set of tables shadowing the populated ones for every later |
| | | * unqualified reference. The path is asked of the connection, so that a lookup answers for exactly |
| | | * the tables the statements behind it reach - no more and no fewer. |
| | | */ |
| | | static final class TableScope { |
| | | /** The database of the connection, or {@code null} where the driver names none - oracle has none. */ |
| | | final String catalog; |
| | | /** |
| | | * The schemas an unqualified name of this connection resolves in, nearest first, or {@code null} |
| | | * where the schema is no dimension of this engine - mysql, whose schema is its database - or |
| | | * where the connection would not say. A null path narrows nothing, which is the question this |
| | | * class asked before there was anything to narrow it by. |
| | | */ |
| | | final List<String> schemas; |
| | | /** |
| | | * Whether the connection answered both questions. One it would not answer leaves the lookup as |
| | | * wide as it ever was - fail-open, which is the safe direction for the schema and the weak one |
| | | * for the database - so the caller asks again rather than latching that answer for the life of a |
| | | * transaction; see {@link ReadableTransactionImpl#takeTableScope()}. |
| | | */ |
| | | final boolean answered; |
| | | |
| | | private TableScope(String catalog, List<String> schemas, boolean answered) { |
| | | this.catalog=catalog; |
| | | this.schemas=schemas; |
| | | this.answered=answered; |
| | | } |
| | | |
| | | /** |
| | | * What this connection says about where an unqualified name of it resolves. The storage is |
| | | * taken because one engine is asked with a statement rather than with a method of its driver, |
| | | * and a statement of this backend takes the bound of its class (#882). |
| | | */ |
| | | static TableScope of(JDBCStorage storage, Connection con) { |
| | | return of(storage, con, true); |
| | | } |
| | | |
| | | /** |
| | | * The same, told to keep quiet about a connection that will not answer. A transaction asks |
| | | * again for as long as it is refused - a lookup left as wide as the whole server decides a |
| | | * create and a drop - and one refusal per tree of the backend is one line per tree in the log, |
| | | * each with a stack trace, for a thing that was already said. |
| | | */ |
| | | static TableScope of(JDBCStorage storage, Connection con, boolean report) { |
| | | String catalog=null; |
| | | List<String> schemas=null; |
| | | boolean answered=true; |
| | | try { |
| | | // an empty name is not the name of a database but a driver's way of saying it has none, |
| | | // and passed to a metadata pattern it means "tables that belong to no catalog" - which is |
| | | // not the same question and would answer nothing |
| | | catalog=emptyToNull(con.getCatalog()); |
| | | } catch (Exception e) { |
| | | // said out loud rather than swallowed: this decides a create and a drop, and a lookup |
| | | // that silently reverts to the whole server is the one failure of the two that cannot be |
| | | // seen from its outcome |
| | | answered=false; |
| | | log(report, "jdbc: this connection would not name the database it works in, so a table of another database of this server may answer for one of this backend's: %s", e); |
| | | } |
| | | try { |
| | | schemas=schemaPathOf(storage, con); |
| | | } catch (Exception e) { |
| | | answered=false; |
| | | log(report, "jdbc: this connection would not name the schemas an unqualified name of it resolves in, so a table of any schema may answer for one of this backend's: %s", e); |
| | | } |
| | | return new TableScope(catalog, schemas, answered); |
| | | } |
| | | |
| | | /** Said once where it is worth saying, and kept for the trace where it would be said again. */ |
| | | private static void log(boolean report, String message, Exception e) { |
| | | if (report) { |
| | | logger.warn(LocalizableMessage.raw(message, stackTraceToSingleLineString(e))); |
| | | } else { |
| | | logger.trace(LocalizableMessage.raw(message, stackTraceToSingleLineString(e))); |
| | | } |
| | | } |
| | | |
| | | /** The schemas an unqualified name resolves in, in the order this engine resolves them. */ |
| | | private static List<String> schemaPathOf(JDBCStorage storage, Connection con) throws SQLException { |
| | | final String driverName=driverNameOf(con); |
| | | if (driverName.contains("mysql")) { |
| | | // the schema of Connector/J is the database, and which of the two names it answers with is |
| | | // the databaseTerm of the connection: with CATALOG - the default - getSchema() answers null |
| | | // and the catalog above is the narrowing, and with SCHEMA it is the other way round. Asked |
| | | // rather than assumed, so that neither setting leaves this lookup narrowed by nothing at all |
| | | final String database=emptyToNull(con.getSchema()); |
| | | return database==null ? null : Collections.singletonList(database); |
| | | } |
| | | if (driverName.contains("postgres")) { |
| | | // getSchema() is "select current_schema()" on pgjdbc - the first existing schema of the |
| | | // search_path - while an unqualified reference resolves across the whole of it. |
| | | // Behind a savepoint, because this runs on the caller's transaction and postgres refuses |
| | | // every further statement of a transaction whose statement failed (25P02): a query this |
| | | // engine turns out not to have - pgjdbc talks to more than one of them - would otherwise |
| | | // surface as the next statement of the caller failing, with the cause nowhere near it |
| | | final Savepoint before=savepoint(con); |
| | | try (final PreparedStatement statement=con.prepareStatement("select unnest(current_schemas(true))")) { |
| | | // bounded like every other statement of this backend (#882), and by the class the lookups |
| | | // this scope narrows take: it reads a session setting rather than the data, and what the |
| | | // savepoint and the fallback below answer for is a query this engine refuses - not one it |
| | | // never answers at all, which is a wait holding the open of a tree with nothing to end it |
| | | final List<String> path=storage.executeResultSet(statement, StatementBound.OPERATION, rs -> { |
| | | final List<String> read=new ArrayList<>(); |
| | | while (rs.next()) { |
| | | final String schema=emptyToNull(rs.getString(1)); |
| | | if (schema!=null) { |
| | | read.add(schema); |
| | | } |
| | | } |
| | | return read; |
| | | }); |
| | | release(con, before); |
| | | if (!path.isEmpty()) { |
| | | return Collections.unmodifiableList(path); |
| | | } |
| | | } catch (Exception e) { // asked of getSchema() below instead, as well as it can say it |
| | | undo(con, before); |
| | | logger.debug(LocalizableMessage.raw("jdbc: unable to read the search path of this connection, which is asked for its current schema instead: %s", |
| | | stackTraceToSingleLineString(e))); |
| | | } |
| | | } |
| | | final String schema=emptyToNull(con.getSchema()); |
| | | if (schema==null) { |
| | | return null; |
| | | } |
| | | if (driverName.contains("microsoft")) { |
| | | // an unqualified name resolves in the default schema of the user and then in dbo |
| | | return Collections.unmodifiableList(Arrays.asList(schema, "dbo")); |
| | | } |
| | | // oracle resolves in the current schema, and past it through synonyms this cannot enumerate: |
| | | // a table reached through one is not found here, and an open creates it again in the schema |
| | | return Collections.singletonList(schema); |
| | | } |
| | | |
| | | /** |
| | | * A point to put a transaction back to, or {@code null} where this connection is in no |
| | | * transaction to speak of or would not take one. A read of the search path is answered by the |
| | | * connection of whoever asked for the scope, and a failed statement of it is theirs to be |
| | | * spared. |
| | | */ |
| | | private static Savepoint savepoint(Connection con) { |
| | | try { |
| | | return con.getAutoCommit() ? null : con.setSavepoint("opendj_search_path"); |
| | | } catch (SQLException | RuntimeException e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** Puts the transaction back to where the probe found it, so that its failure stays the probe's. */ |
| | | private static void undo(Connection con, Savepoint savepoint) { |
| | | if (savepoint!=null) { |
| | | try { |
| | | con.rollback(savepoint); |
| | | } catch (SQLException | RuntimeException e) {} |
| | | } |
| | | } |
| | | |
| | | /** Gives up a savepoint nothing needs any more: a transaction keeps them all until it ends. */ |
| | | private static void release(Connection con, Savepoint savepoint) { |
| | | if (savepoint!=null) { |
| | | try { |
| | | con.releaseSavepoint(savepoint); |
| | | } catch (SQLException | RuntimeException e) {} |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Whether the table this row of a listing describes is one this connection reaches. The metadata |
| | | * pattern alone does not settle it: a schema reaches {@link DatabaseMetaData#getTables} as a |
| | | * pattern, where "_" is a single-character wildcard, so a listing narrowed to a schema named |
| | | * "app_data" is answered for by one named "appXdata" as well. The listings of this class are |
| | | * asked with no schema pattern at all - a path is more than one name anyway - and read through |
| | | * this instead. |
| | | */ |
| | | boolean covers(ResultSet rs) throws SQLException { |
| | | return isSameCatalog(rs.getString("TABLE_CAT")) && isOnSchemaPath(rs.getString("TABLE_SCHEM")); |
| | | } |
| | | |
| | | /** |
| | | * Whether the database of a listed table rules it out. A name neither side gives is no |
| | | * narrowing: a driver naming no catalog of its own - oracle has none - must not be read as |
| | | * naming another. |
| | | */ |
| | | private boolean isSameCatalog(String ofTable) { |
| | | return catalog==null || ofTable==null || ofTable.isEmpty() || catalog.equalsIgnoreCase(ofTable); |
| | | } |
| | | |
| | | /** Whether a listed table is in one of the schemas an unqualified name of this connection resolves in. */ |
| | | private boolean isOnSchemaPath(String ofTable) { |
| | | if (schemas==null || schemas.isEmpty() || ofTable==null || ofTable.isEmpty()) { |
| | | return true; |
| | | } |
| | | for (final String schema : schemas) { |
| | | if (schema.equalsIgnoreCase(ofTable)) { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** How the database and the schemas a table count was taken over are named in a log line. */ |
| | | String name() { |
| | | final String where=schemas==null || schemas.isEmpty() ? null : String.join(", ", schemas); |
| | | if (catalog!=null && where!=null) { |
| | | return catalog+"."+where; |
| | | } |
| | | if (catalog!=null) { |
| | | return catalog; |
| | | } |
| | | return where!=null ? where : "this connection"; |
| | | } |
| | | |
| | | /** An empty name is the name of nothing: see {@link #of}. */ |
| | | private static String emptyToNull(String name) { |
| | | return name==null || name.isEmpty() ? null : name; |
| | | } |
| | | } |
| | | |
| | | //operation |
| | | /** |
| | | * {@inheritDoc} |
| | |
| | | try (con) { |
| | | driver=driverNameOf(con); |
| | | final WriteableTransactionTransactionImpl txn=new WriteableTransactionTransactionImpl(con); |
| | | //the connect of the catalog is made inside this attempt and retries the way a borrow does, |
| | | //up to the pool timeout - six times this window at the defaults. Left to its own deadline |
| | | //it would spend a window it does not own and hand the loop a failure it has classified as |
| | | //replayable with nothing left to replay it in, so it is told where the window ends |
| | | txn.catalogSession.boundedAlsoBy(giveUpAt); |
| | | try { |
| | | writeOperation.run(txn); |
| | | committing=true; |
| | |
| | | failure=e; |
| | | throw e; |
| | | } finally { // the comment connection lives no longer than the trees it stamped, and no longer |
| | | // than the attempt that opened it: a replay stamps on a session of its own |
| | | // than the attempt that opened it: a replay stamps on a session of its own. The catalog |
| | | // connection goes with it, having written every row this attempt had to enrol - and |
| | | // committed each of them, so a replay finds them recorded and writes none again |
| | | partlyCommitted=txn.partlyCommitted; |
| | | try { |
| | | txn.stampSession.close(); |
| | | try { |
| | | txn.stampSession.close(); |
| | | } finally { |
| | | txn.catalogSession.close(); |
| | | } |
| | | } catch (RuntimeException e) { |
| | | //the stamp is a diagnostic aid and must not become the outcome of the write: an unchecked |
| | | //throw out of a driver's close() would otherwise replace the failure being unwound (JLS |
| | |
| | | return isExistsTable(treeName); |
| | | } |
| | | |
| | | /** |
| | | * Where an unqualified name of this transaction's connection resolves, asked of it once. Every |
| | | * lookup of a table below is narrowed to it - the reason is in {@link TableScope} - and asking |
| | | * per lookup would cost a round trip per tree of the backend on every open: pgjdbc answers both |
| | | * halves of it with a select of its own. A transaction holds one connection for the whole of its |
| | | * life, so one answer serves it all. |
| | | */ |
| | | // not private: a private member is not inherited, and the writeable transaction below asks |
| | | // for the scope of its own lookups through it |
| | | TableScope tableScope; |
| | | |
| | | TableScope takeTableScope() { |
| | | // a connection that would not answer is asked again rather than latched: what it says |
| | | // decides a create and a drop, and one refused question would otherwise leave every lookup |
| | | // of this transaction as wide as the whole server. Only the first refusal of a transaction |
| | | // is reported: the ones behind it are the same connection saying the same thing, once per |
| | | // tree of the backend |
| | | if (tableScope==null || !tableScope.answered) { |
| | | tableScope=TableScope.of(JDBCStorage.this, con, tableScope==null); |
| | | } |
| | | return tableScope; |
| | | } |
| | | |
| | | // Readable, not writeable: the caller that asks about a tree this backend does not own is |
| | | // the compressed schema migration (#873), which probes the shared tree from the writeable |
| | | // transaction of RootContainer.open() but must not create or enrol it. Answering that from |
| | | // the readable transaction keeps the probe available to every reader, and costs nothing: |
| | | // the writeable one inherits it. |
| | | // The name it asks about is the non-enrolling one for that same reason, and the question is |
| | | // narrowed to where an unqualified name of this connection resolves, like every other table |
| | | // lookup of this class: see isExistsTable(Connection, TableScope, String). |
| | | boolean isExistsTable(TreeName treeName) { |
| | | final String tableName = readTableName(treeName); |
| | | // the catalog lookup guarding a create table is bounded as the operation it is, not as |
| | | // the bulk statement it guards, and not as the class of the transaction that happens to |
| | | // ask: it reads a data dictionary rather than the data, so a wait here is the metadata |
| | | // lock of another session |
| | | try { |
| | | return bounded(con, StatementBound.OPERATION, () -> { |
| | | final DatabaseMetaData metaData = con.getMetaData(); |
| | | // asked of the catalog by name: openTree(createOnDemand) calls this for every tree |
| | | // of the backend - about 25 of them for a stock suffix, on every open - and listing |
| | | // every table of the database each time costs the whole catalog once per tree, on a |
| | | // database this backend may well be sharing with something else |
| | | try (final ResultSet rs = metaData.getTables(null, null, |
| | | storedIdentifier(metaData, tableName), new String[]{"TABLE"})) { |
| | | while (rs.next()) { |
| | | // the name still has to be compared: "_" is a single-character wildcard in a |
| | | // metadata pattern, so "opendj_<hash>" also matches a table named "opendjX<hash>" |
| | | if (tableName.equalsIgnoreCase(rs.getString("TABLE_NAME"))) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | return false; |
| | | }); |
| | | } catch (Exception e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | return JDBCStorage.this.isExistsTable(con, takeTableScope(), readTableName(treeName)); |
| | | } |
| | | } |
| | | /** |
| | |
| | | // write() (and by ImporterImpl.close()) when the transaction is done with. |
| | | final StampSession stampSession=new StampSession(); |
| | | |
| | | // The connection the catalog rows of this transaction are written on, opened at the first |
| | | // row there is to write and closed with the transaction, like the stamp session above. |
| | | final CatalogSession catalogSession=new CatalogSession(); |
| | | |
| | | /** |
| | | * Whether this transaction has committed part of its own work, which takes the attempt out of the |
| | | * replay of {@link JDBCStorage#write}: what it did no longer rolls back as a whole, and a |
| | |
| | | public void openTree(TreeName treeName, boolean createOnDemand) { |
| | | if (createOnDemand) { |
| | | checkReadOnly(); |
| | | // what makes this tree nameable by a process which has opened nothing: see |
| | | // getCatalogTree(). Written before the table and not after it, on a connection of the |
| | | // catalog's own and committed there, so that the table is never there without a row |
| | | // naming it - on every engine, and not only on the ones whose DDL happens to carry the |
| | | // row along - and so that none of it commits the work of this transaction. Of the two |
| | | // ways a half-done open can end, a catalog naming a table that is not there is the one |
| | | // the removal is ready for - it skips such a row and says so - while a table nothing |
| | | // names is adopted with its stale rows by the next open of that tree and is dropped by no |
| | | // clear ever after. deleteTree() takes the row out after the drop for that same reason, |
| | | // which is why it is not the mirror of this. It writes, so it comes after the read-only |
| | | // check and not before it (#874) |
| | | enrolInCatalog(treeName); |
| | | // Every statement below is a DDL that commits, and each raises partlyCommitted through |
| | | // commitStatement() rather than once for the method: every one of them is guarded by a |
| | | // catalog read, so on an existing backend this method issues nothing at all. Raising the |
| | |
| | | }else if (driverName.contains("oracle")) { |
| | | try { |
| | | // oracle has no "create index if not exists"; unquoted identifiers are stored in uppercase |
| | | if (!isExistsIndex(tableName.toUpperCase(),"k_"+tableName.substring("opendj_".length()))) { |
| | | if (!isExistsIndex(tableName.toUpperCase(Locale.ROOT),"k_"+tableName.substring("opendj_".length()))) { |
| | | commitStatement("create index k_"+tableName.substring("opendj_".length())+" on "+tableName+" (k)", true); |
| | | } |
| | | }catch (SQLException e) { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Records the tree in the catalog of this backend, creating the catalog itself along the way |
| | | * when this is the first tree of the storage. Enrolling is the business of |
| | | * openTree(createOnDemand) alone: naming a tree in order to read it must never put it up for |
| | | * removal, since the tree read may belong to another backend of the same database - the |
| | | * unqualified compressed schema trees such a database may still hold, say (#873). |
| | | * <p> |
| | | * The row is written whenever the catalog does not already record this tree at this table - |
| | | * and not only when the table is created - so that a backend of an installation upgraded to a |
| | | * version keeping a catalog fills it in at its first read-write open instead of waiting for |
| | | * its trees to be created again. What the catalog already records is read once, when this |
| | | * storage first opens it; see {@link #enrolledTrees}. |
| | | * <p> |
| | | * The row is written on a connection of the catalog's own and committed there, never on the one |
| | | * this transaction runs on. It has to be committed: the open which fills the catalog of a |
| | | * backend upgraded from a version keeping none creates no table at all, so there is nothing |
| | | * else of {@link #openTree} to carry those rows, and a transaction failing after them would |
| | | * take every one back - leaving the tables named by nothing and the next clear dropping |
| | | * nothing, which is #888 over again. And that commit must not be this transaction's: |
| | | * {@code RootContainer.open()} opens every tree of every base DN in a single write, a commit |
| | | * anywhere inside it takes the whole write out of the replay - {@link #replayReason} reads |
| | | * {@link #partlyCommitted} before it asks anything else - and a deadlock at the twentieth tree |
| | | * would then fail the backend open where master replayed it. A connection of its own is what |
| | | * gives the row a commit that is not the caller's. |
| | | */ |
| | | void enrolInCatalog(TreeName treeName) { |
| | | final TreeName catalog=getCatalogTree(); |
| | | if (catalog.equals(treeName)) { |
| | | return; // the catalog holds no row of its own: catalogTables() adds it when its table is there |
| | | } |
| | | if (SHARED_COMPRESSED_SCHEMA_BASE_DN.equals(treeName.getBaseDN())) { |
| | | return; // a tree this backend may not be the only owner of: see the constant |
| | | } |
| | | openCatalog(catalog); |
| | | if (enrolledTrees.contains(treeName)) { |
| | | return; // already recorded, at the table this open would record it at |
| | | } |
| | | try { |
| | | catalogSession.transaction().upsert(catalog, |
| | | ByteString.valueOfUtf8(treeName.toString()), |
| | | ByteString.valueOfUtf8(getTableName(treeName))); |
| | | // committed where it is written, so that the row is there before the table on every |
| | | // engine and not only where the "create table" below happens to carry it - and on the |
| | | // catalog's own connection, so that this commit is none of the caller's: see above |
| | | catalogSession.commit(); |
| | | enrolledTrees.add(treeName); |
| | | } catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well, exactly as unenrolFromCatalog() takes it: upsert() answers a |
| | | // failed statement with a StorageRuntimeException of its own, and what that statement left |
| | | // behind has to be rolled back all the same. This connection outlives the row that failed |
| | | // on it and carries every remaining tree of this open - postgres refuses every further |
| | | // statement of a transaction whose statement failed (25P02), so a reset skipped here fails |
| | | // the twenty-odd enrolments behind it with a cause nowhere near the one that started it |
| | | catalogSession.reset(); |
| | | throw e instanceof StorageRuntimeException ? (StorageRuntimeException) e : new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The connection the catalog is read and written on, established where this transaction has |
| | | * not established it yet. |
| | | * <p> |
| | | * The unchecked failure of the connect is taken like the checked one, the way every other |
| | | * catalog path of this class takes it: {@link JDBCStorage#newCatalogConnection} hands on a |
| | | * driver's unchecked answer to a connect it will not make as the unchecked failure it is, so a |
| | | * catch of {@code SQLException} alone would let that one past unwrapped and without the line |
| | | * saying which connection of this backend could not be made. |
| | | */ |
| | | Connection catalogConnection() { |
| | | try { |
| | | return catalogSession.connection(); |
| | | } catch (SQLException | RuntimeException e) { |
| | | throw e instanceof StorageRuntimeException ? (StorageRuntimeException) e |
| | | : new StorageRuntimeException("jdbc: backend "+config.getBackendId() |
| | | +" could not open the connection its tree catalog is read and written on", e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Makes the catalog of this backend usable, once per open of the storage: its table is created |
| | | * where there is none, and what it already records is read where there is one. |
| | | * <p> |
| | | * Serialized on the storage, so that two transactions opening trees at the same time cannot both |
| | | * find the table absent and both go on to create it. It serializes this storage and nothing |
| | | * else, which is why the create tolerates a table that turned up while it was being made: an |
| | | * offline tool beside a running server is a pair no lock of one process can order. The stamp - |
| | | * the one thing under it that is nobody's dependency - is issued outside it. |
| | | * <p> |
| | | * Two things are kept out of the lock because they are the slow ones. The flag is read before |
| | | * it is taken at all, which is every {@code openTree} of this storage but the first few: it is |
| | | * volatile and it is raised after {@link #enrolledTrees} has been filled, so a reader that sees |
| | | * it up sees that memo whole. And the connection of the catalog is established before it: that |
| | | * connect retries a database taking no connection for the moment for up to the deadline of a |
| | | * borrow (see {@link JDBCStorage#newCatalogConnection}), and made under the lock it would hold |
| | | * every other transaction of this storage that goes on to open a tree for the whole of that |
| | | * wait - a queue the borrows of the pool, each waiting on its own thread, never form. |
| | | * <p> |
| | | * What that costs is a connect to every transaction which finds the flag down and then loses the |
| | | * race for the lock. The loser gives that connection up rather than hold it: the winner has |
| | | * filled {@link #enrolledTrees}, so the caller is about to find its tree recorded and write |
| | | * nothing at all, and the session is lazy - the rarer loser that does have a tree to enrol opens |
| | | * another. The race is for the first openTree of a storage, so what this can cost against a |
| | | * database with no connection to give is one refused login per racing transaction, where the |
| | | * connect made under the lock cost one and made the others wait out the same refusal in turn. |
| | | */ |
| | | void openCatalog(TreeName catalog) { |
| | | if (catalogTableOpened) { |
| | | return; |
| | | } |
| | | // what this call established, and not what the transaction was already holding: deleteTree() |
| | | // opens the session before it drops anything, so a later openTree of the same transaction |
| | | // must not give away a connection it did not make |
| | | final boolean established=!catalogSession.isEstablished(); |
| | | catalogConnection(); |
| | | final boolean lostTheRace; |
| | | synchronized (catalogLock) { |
| | | lostTheRace=catalogTableOpened; |
| | | if (!lostTheRace) { |
| | | if (isExistsTable(catalog)) { |
| | | readEnrolledTrees(catalog); |
| | | } else { |
| | | createCatalogTable(catalog); |
| | | // nothing to read from a table that has just been created, and nothing this open |
| | | // enrols may be skipped as already recorded |
| | | } |
| | | catalogTableOpened=true; |
| | | } |
| | | } |
| | | if (lostTheRace) { |
| | | if (established) { |
| | | // the winner has filled enrolledTrees, so the caller is about to find its tree recorded |
| | | // and write nothing: the connection this call made is given up rather than held idle for |
| | | // the rest of the transaction, and the session being lazy, the rarer loser that does |
| | | // have a tree to enrol opens another. Outside the lock, for the reason the connect is: |
| | | // a close is a round trip of its own, and against a database that has stopped answering |
| | | // it does not return at all - connectCatalog() lifts the read bound of the login on |
| | | // every connection it hands back, so there is no bound of ours left to end this one |
| | | catalogSession.close(); |
| | | } |
| | | return; |
| | | } |
| | | // stamped with its tree name like any table of a tree (#866), and for a reason of its own: a |
| | | // clear reports what it did not drop, and the catalog of a backend sharing this database |
| | | // (#873) is the one table such a report could otherwise attribute to nobody. It costs one |
| | | // stamp per open of the storage, not one per tree - the flag above is what keeps it to one - |
| | | // and it is issued outside the lock: it is a diagnostic aid on a session and a bound of its |
| | | // own, with no business holding up every openTree of this storage |
| | | commentTable(catalog, dialectOf(con), stampSession); |
| | | } |
| | | |
| | | /** |
| | | * Reads what the catalog already records, so that the trees it names are not enrolled again on |
| | | * an open which would write the rows that are already there; see {@link #enrolledTrees}. Run |
| | | * once per open of the storage, behind the very flag that keeps the catalog from being opened |
| | | * again, and it costs the one select a clear pays for anyway. |
| | | * <p> |
| | | * Read on the catalog's own connection and committed there, so that no transaction of a caller |
| | | * ever touches the catalog table. A select of the caller's transaction would hold a lock on it |
| | | * until that transaction ended - the whole of {@code RootContainer.open()} - and the rows this |
| | | * read decides are written on the catalog's connection: a clear of this backend queueing for the |
| | | * table in between would then be waiting for the caller while the caller waited for it, a pair |
| | | * of sessions no deadlock detector of the database can see, one of them being blocked inside |
| | | * this process rather than in the server. {@link #removeStorageFiles()} and {@link #listTrees()} |
| | | * read that table on a connection of their own, which is the same argument read the other way: |
| | | * neither is inside a transaction of a caller, and both are done with it when they commit. |
| | | */ |
| | | void readEnrolledTrees(TreeName catalog) { |
| | | try { |
| | | final Connection catalogCon=catalogSession.connection(); |
| | | for (final Map.Entry<TreeName,String> row : readCatalogRows(catalogCon, getTableName(catalog)).entrySet()) { |
| | | // a row recording another table than this version would record is not the row this |
| | | // open would leave behind: a removal drops the table the row records, so such a row is |
| | | // rewritten - and committed - exactly like one that is not there at all. Asked through |
| | | // the non-enrolling name of #881: reading what the catalog records is not taking an |
| | | // interest in the tree it names, and a row this decides not to trust must not have put |
| | | // its tree in the memo of the trees this backend names its tables for |
| | | if (readTableName(row.getKey()).equals(row.getValue())) { |
| | | enrolledTrees.add(row.getKey()); |
| | | } |
| | | } |
| | | catalogCon.commit(); // the read ends here and holds nothing of the catalog after it |
| | | } catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well, for the reason enrolInCatalog() takes it: this connection is |
| | | // the one every enrolment of this open goes on to write its row on |
| | | catalogSession.reset(); |
| | | throw e instanceof StorageRuntimeException ? (StorageRuntimeException) e : new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Creates the table of the catalog, on the catalog's own connection for the reason its rows are |
| | | * written there - see {@link #enrolInCatalog}. It takes no index of the kind openTree() gives a |
| | | * tree: the catalog is read whole and written by key, never iterated by key range, so the index |
| | | * a cursor needs would serve nothing here. The stamp it does take is given by the caller, on |
| | | * every open rather than on creation alone. |
| | | * <p> |
| | | * A read-write open of a JDBC backend needs the privilege to create this table, where a version |
| | | * keeping no catalog issued no DDL at all on an installation whose tables were already there. |
| | | * An account that may write its rows but not create a table is a configuration this can meet, |
| | | * so the failure says which table it was and why the backend wanted it, rather than reaching |
| | | * the operator as a bare SQL error inside ERR_OPEN_ENV_FAIL. |
| | | */ |
| | | void createCatalogTable(TreeName catalog) { |
| | | final String tableName=getTableName(catalog); |
| | | try { |
| | | final Connection catalogCon=catalogSession.connection(); |
| | | try (final PreparedStatement statement=catalogCon.prepareStatement("create table "+tableName+" ("+getTableDialect()+")")) { |
| | | // bulk like every other create table of this backend (#882): it is DDL nobody waits on, |
| | | // and the class of a client operation is not what a statement of this kind can be given |
| | | execute(statement, StatementBound.BULK); |
| | | } |
| | | catalogCon.commit(); |
| | | } catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well, for the reason enrolInCatalog() takes it: what the statement |
| | | // left behind has to be rolled back whatever class the failure arrived in, this connection |
| | | // being the one the rows of this open are written on |
| | | catalogSession.reset(); |
| | | // a table that turned up between the lookup and this statement is what was wanted, whoever |
| | | // made it: the lock this runs under orders the transactions of one storage, and an offline |
| | | // tool beside a running server - the pair #888 is about - is ordered by nothing at all. |
| | | // The lookup is asked inside a catch and must not become the answer: it goes to the |
| | | // database on the caller's connection, which is often the very thing that has just failed, |
| | | // and it reports its own failure as a StorageRuntimeException - thrown from here it would |
| | | // replace the create failure below with a bare metadata error saying nothing about the |
| | | // catalog. So a lookup that will not answer is carried by the failure it could not settle. |
| | | boolean alreadyThere; |
| | | try { |
| | | alreadyThere=isExistsTable(catalog); |
| | | } catch (RuntimeException lookup) { |
| | | e.addSuppressed(lookup); |
| | | alreadyThere=false; |
| | | } |
| | | if (alreadyThere) { |
| | | logger.debug(LocalizableMessage.raw("jdbc: table %s was created by another session while this one was creating it: %s", |
| | | tableName, stackTraceToSingleLineString(e))); |
| | | return; |
| | | } |
| | | throw new StorageRuntimeException("jdbc: backend "+config.getBackendId()+" could not create table " |
| | | +tableName+", which holds the catalog naming the trees it owns: a read-write open of a JDBC" |
| | | +" backend needs the privilege to create it, and a clear of one names nothing without it", e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Takes the tree out of the catalog: a row is what puts a table up for removal, and this one is |
| | | * gone. Written and committed on the catalog's own connection, like the enrolment - see {@link |
| | | * #enrolInCatalog} - which is what keeps the caller's transaction from being able to roll it |
| | | * back over a table that is already dropped. |
| | | */ |
| | | void unenrolFromCatalog(TreeName treeName, boolean enrolled) { |
| | | final TreeName catalog=getCatalogTree(); |
| | | if (catalog.equals(treeName)) { |
| | | catalogTableOpened=false; // its own table is gone: the next enrolment creates it again |
| | | enrolledTrees.clear(); // and records every tree anew, this one having recorded nothing |
| | | return; |
| | | } |
| | | if (SHARED_COMPRESSED_SCHEMA_BASE_DN.equals(treeName.getBaseDN())) { |
| | | // the symmetry of enrolInCatalog() and nothing more: no row of this pair was ever written, |
| | | // so the delete would find none. What keeps the pair out of a clear is that a clear drops |
| | | // what the catalog names and the catalog does not name them; see the constant |
| | | return; |
| | | } |
| | | if (!enrolled) { |
| | | // no row to delete - the catalog table is not there at all - and nothing to order this |
| | | // against: a tree the catalog does not name is not one an enrolment may skip |
| | | enrolledTrees.remove(treeName); |
| | | return; |
| | | } |
| | | try { |
| | | // deleteRow() and not delete(): the read-only check belongs to the caller of deleteTree, |
| | | // which made it, and the transaction this row is written through is one of this class's own |
| | | catalogSession.transaction().deleteRow(catalog, ByteString.valueOfUtf8(treeName.toString())); |
| | | catalogSession.commit(); |
| | | } catch (SQLException | RuntimeException e) { |
| | | // the unchecked one as well: deleteRow() answers a failed statement with a |
| | | // StorageRuntimeException, and what that statement left behind has to be rolled back all |
| | | // the same - this connection outlives the row that failed on it |
| | | catalogSession.reset(); |
| | | throw e instanceof StorageRuntimeException ? (StorageRuntimeException) e : new StorageRuntimeException(e); |
| | | } finally { |
| | | // taken out of what this storage knows the catalog records after the delete and never |
| | | // before it, so that the memo and the catalog never disagree in the direction that |
| | | // makes an enrolment write a row a committed delete then takes back out. After the |
| | | // attempt whatever became of it: a delete that failed leaves a row the next enrolment |
| | | // has to write again rather than skip as already recorded, which costs an upsert of a |
| | | // row that is already there and no more. |
| | | // |
| | | // What no ordering of these two lines can do is order this against an openTree of the |
| | | // very same tree on another thread, and it is worth saying which fix was ruled out |
| | | // rather than leaving it to be proposed again. Such an openTree landing between the |
| | | // commit above and this line skips its enrolment - the memo still names the tree - and |
| | | // goes on to create the table, leaving a table nothing names; the ordering before this |
| | | // one reached the same end state by the other route, the enrolment writing a row this |
| | | // delete then removed. A lock over the memo and the row closes neither, since the |
| | | // table is created and dropped outside it either way: only a lock held across the DDL |
| | | // of both would, and that one deadlocks. A transaction holding catalogLock and blocked |
| | | // in the database on a "drop table" of a tree a second transaction of this storage is |
| | | // still writing would be waiting for that transaction, while it waited for the lock at |
| | | // its next openTree - a cycle the database cannot see, where today it is a plain wait |
| | | // that ends when the second transaction does. |
| | | // |
| | | // So the catalog is consistent given that no two transactions open and delete the same |
| | | // tree at once, and that is the layer above's to keep: a tree is opened read-write and |
| | | // deleted from the configuration framework, which orders the changes of one entry, or |
| | | // from EntryContainer.clear() with the backend disabled. |
| | | enrolledTrees.remove(treeName); |
| | | } |
| | | } |
| | | |
| | | /** Whether a delete of this tree has a row of the catalog to take out; see {@link #unenrolFromCatalog}. */ |
| | | boolean isEnrolledTree(TreeName treeName) { |
| | | if (getCatalogTree().equals(treeName) || SHARED_COMPRESSED_SCHEMA_BASE_DN.equals(treeName.getBaseDN())) { |
| | | return false; |
| | | } |
| | | return catalogTableOpened || isExistsTable(getCatalogTree()); |
| | | } |
| | | |
| | | /** |
| | | * Whether the table already carries the index of this name, asked where the table itself is |
| | | * asked for - see {@link TableScope}. A table name carries no backend id and no database, so two |
| | | * databases of one server hold identical table <em>and</em> index names, and Connector/J 8 binds |
| | | * no schema predicate for a null catalog: a neighbouring database answering here would skip the |
| | | * create index of this one for good, leaving every "where k>? order by k" batch of every cursor |
| | | * a full scan behind it. |
| | | */ |
| | | boolean isExistsIndex(String tableName, String indexName) throws SQLException { |
| | | final TableScope scope=takeTableScope(); |
| | | // the index lookup takes the operation bound of #882 like every other catalog read of this |
| | | // class: it asks a data dictionary rather than the data, so a wait here is the metadata lock |
| | | // of another session - and it is narrowed to the scope every table lookup here is narrowed to |
| | | return bounded(con, StatementBound.OPERATION, () -> { |
| | | // approximate=true: with false the oracle driver runs ANALYZE on every call |
| | | try (final ResultSet rs = con.getMetaData().getIndexInfo(null, null, tableName, false, true)) { |
| | | try (final ResultSet rs = con.getMetaData().getIndexInfo(scope.catalog, null, tableName, false, true)) { |
| | | while (rs.next()) { |
| | | if (indexName.equalsIgnoreCase(rs.getString("INDEX_NAME"))) { |
| | | if (indexName.equalsIgnoreCase(rs.getString("INDEX_NAME")) && scope.covers(rs)) { |
| | | return true; |
| | | } |
| | | } |
| | |
| | | @Override |
| | | public void deleteTree(TreeName treeName) { |
| | | checkReadOnly(); |
| | | // The row is taken out on the catalog's own connection rather than left to this transaction: |
| | | // that transaction is the last thing the delete could still be rolled back by - write() |
| | | // replays a class 40 conflict and rethrows everything else unreplayed - and the row would be |
| | | // rolled back over a table that is already gone, with nothing ever to put it right: a deleted |
| | | // tree is not opened again, so no enrolment and no unenrolment reaches it a second time. It |
| | | // holds for the branch where there is no table to drop as much as for the one where the drop |
| | | // commits of its own accord. |
| | | // That connection is opened here, before anything is dropped: a connect this backend cannot |
| | | // make costs nothing at this point, where one failing after the drop would leave exactly the |
| | | // half-done state the sentence above is about. |
| | | final boolean enrolled=isEnrolledTree(treeName); |
| | | if (enrolled) { |
| | | catalogConnection(); |
| | | } |
| | | // The table dropped is the one the tree names, where a clear drops the one its row records. |
| | | // The two are the same table by the time anything is deleted: a row recording another one is |
| | | // not taken as an enrolment - readEnrolledTrees() keeps it out of enrolledTrees - so the |
| | | // openTree that every delete of a tree comes after has rewritten it to this name. |
| | | // A row is written before its table is created and taken out after its table is dropped, |
| | | // never the other way round: of the two ways a half-done change can end, a catalog naming a |
| | | // table that is not there is the one the removal is ready for - it skips such a row and says |
| | | // so - while a table nothing names is adopted with its stale rows by the next open of that |
| | | // tree and is dropped by no clear ever after. So this is deliberately not the mirror of |
| | | // openTree(): an unenrolment left pending before the drop would be committed by the drop |
| | | // itself on mysql and oracle, where DDL commits the transaction it finds open before it |
| | | // executes, and would then stand even where the drop goes on to fail - ORA-00054 on a tree |
| | | // another session holds, say, which write() does not replay, it being neither a class 40 |
| | | // state nor ORA-00060. |
| | | if (isExistsTable(treeName)) { |
| | | try { |
| | | commitStatement("drop table " + getTableName(treeName), true); |
| | |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | // forget the mapping so listTrees() consumers (updateTableStatistics) skip the dropped table |
| | | unenrolFromCatalog(treeName, enrolled); |
| | | // the memoized table name of a tree nothing holds any more is of no use to anyone |
| | | tree2table.invalidate(treeName); |
| | | unstampableTrees.remove(treeName); // a table recreated later deserves a fresh stamp attempt |
| | | } |
| | |
| | | @Override |
| | | public boolean delete(TreeName treeName, ByteSequence key) { |
| | | checkReadOnly(); |
| | | return deleteRow(treeName, key); |
| | | } |
| | | |
| | | /** |
| | | * The statement of {@link #delete} without its read-only check, for the rows this class writes |
| | | * on a transaction of its own making: the catalog of a backend is written through a transaction |
| | | * over a connection of its own, whose access mode is read again as it is built, and the check |
| | | * that matters was made by the caller of {@code openTree} or {@code deleteTree}. |
| | | */ |
| | | boolean deleteRow(TreeName treeName, ByteSequence key) { |
| | | try (final PreparedStatement statement=con.prepareStatement("delete from "+getTableName(treeName)+" where h="+hashParam(con)+" and k=?")){ |
| | | statement.setString(1,key2hash.get(ByteBuffer.wrap(key.toByteArray()))); |
| | | statement.setBytes(2,real2db(key.toByteArray())); |
| | |
| | | throw new UnsupportedOperationException(); |
| | | } |
| | | if (writeTableName==null) { |
| | | // the enrolling name, unlike the read statements above: this writes to the tree, so |
| | | // it is one this backend owns, and removeStorageFiles() has to know about it |
| | | // the enrolling name, unlike the read statements above: this writes to the tree, so it is |
| | | // one this backend owns and its table belongs in the memo of the storage. What a clear |
| | | // drops is what the catalog of the backend names (#888), and openTree(name, true) is the |
| | | // one thing that writes there - a tree written through a cursor is one the backend opened |
| | | // to get the cursor, which is where its row comes from |
| | | writeTableName=getTableName(treeName); |
| | | } |
| | | try (final PreparedStatement statement=con.prepareStatement("delete from "+writeTableName+" where h="+hashParam(con)+" and k=?")){ |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * {@inheritDoc} |
| | | * <p> |
| | | * Answered from the catalog of the backend rather than from the trees this process happens to |
| | | * have touched: {@link #removeStorageFiles()} runs before anything has touched one (#888). |
| | | * <p> |
| | | * What a tool has to be shown is not what a clear may drop: the shared compressed schema trees |
| | | * are deliberately not enrolled - a backend must not offer a tree another one may own for removal |
| | | * - and would go unnamed by {@code dbtest} for it, so they are added here when their tables are |
| | | * there. {@link #catalogTables(Connection, TableScope)} is what the removal reads, and it |
| | | * names them not. |
| | | * <p> |
| | | * The catalog itself is among the names, being a tree of this backend like any other: {@code |
| | | * dbtest list-raw-dbs} counts it and {@code dump-raw-db} resolves its name, which is the one way |
| | | * of seeing from outside the server what a clear of this backend would drop. |
| | | */ |
| | | @Override |
| | | public Set<TreeName> listTrees() { |
| | | return tree2table.asMap().keySet(); |
| | | // validated, like the borrows of open() and removeStorageFiles(): since the catalog this reads |
| | | // from, this borrow issues its statements far from itself and compensates a dropped connection |
| | | // in no other way - a write is replayed and a read tells the pool, and this does neither, so a |
| | | // connection dropped inside the alive window would surface out of a listing of tree names |
| | | try (final Connection con=getValidatedConnection()) { |
| | | return listTrees(con); |
| | | } catch (StorageRuntimeException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | throw new StorageRuntimeException(e); |
| | | } |
| | | } |
| | | |
| | | Set<TreeName> listTrees(Connection con) throws SQLException { |
| | | final TableScope scope=TableScope.of(this, con); |
| | | final Set<TreeName> trees=new HashSet<>(catalogTables(con, scope).keySet()); |
| | | for (final TreeName treeName : SHARED_COMPRESSED_SCHEMA_TREES) { |
| | | // asked of the database, not assumed: the pair belongs to no backend in particular, and |
| | | // once #881 gives each backend a pair of its own an installation may hold neither table. |
| | | // Narrowed to this database: a pair of the same name in another database of the server |
| | | // would otherwise have this backend name two trees it does not hold |
| | | if (isExistsTable(con, scope, readTableName(treeName))) { |
| | | trees.add(treeName); |
| | | } |
| | | } |
| | | return trees; |
| | | } |
| | | |
| | | /** |
| | | * The trees the catalog of this backend names, each with the table recorded as holding it, the |
| | | * catalog itself among them. Empty when the catalog table is not there - a backend which has |
| | | * never been opened read-write - which is what tells {@link #removeStorageFiles()} it has nothing |
| | | * it may drop. |
| | | * <p> |
| | | * The table name is taken from the row rather than recomputed from the tree name, so that a |
| | | * removal drops what was enrolled even if the naming of tables were ever to change. |
| | | * <p> |
| | | * The scope is the caller's rather than asked for here: it is not free of a round trip - pgjdbc |
| | | * answers both halves of it with a select of its own - and a clear and a listTrees() both narrow a |
| | | * lookup of their own by it, so they pass what they have instead of every reader asking twice over. |
| | | */ |
| | | Map<TreeName,String> catalogTables(Connection con, TableScope scope) throws SQLException { |
| | | return catalogTables(con, scope, new ArrayList<>()); // nobody to tell: the descriptions go nowhere |
| | | } |
| | | |
| | | /** |
| | | * The same, telling the caller what the read passed over: a clear accounts for every row of its |
| | | * catalog, and a row it could not act on is one nothing else in its report would name - the table |
| | | * such a row records is outside the namespace {@link #leftoverTables} scans. See {@link |
| | | * #reportClearOutcome}. |
| | | */ |
| | | Map<TreeName,String> catalogTables(Connection con, TableScope scope, List<String> skippedRows) throws SQLException { |
| | | final TreeName catalogTree=getCatalogTree(); |
| | | final String catalogTable=getTableName(catalogTree); |
| | | // narrowed to this database: a catalog of the same name in another database of the server |
| | | // would send the select below at a table that is not here, failing the clear it answers |
| | | if (!isExistsTable(con, scope, catalogTable)) { |
| | | return Collections.emptyMap(); |
| | | } |
| | | final Map<TreeName,String> trees=readCatalogRows(con, catalogTable, skippedRows); |
| | | // The catalog names every tree of the backend but itself, and is put last on purpose: the |
| | | // removal drops the trees in this order, and what names them has to outlive them. Dropping a |
| | | // table is DDL, which mysql and oracle commit as they go, so a removal that fails halfway is |
| | | // finished by the next attempt rather than leaving behind tables nothing names any more. |
| | | trees.remove(catalogTree); // no row should name it; one that does must not hold back the order |
| | | trees.put(catalogTree, catalogTable); |
| | | return trees; |
| | | } |
| | | |
| | | /** |
| | | * The rows of the catalog table as they stand, tree by tree: the caller has already established |
| | | * that the table is there - {@link #catalogTables} by a lookup of its own, an enrolment by having |
| | | * just created it or found it - so this asks the database nothing but the select. |
| | | * <p> |
| | | * A row this backend cannot have written is skipped and reported rather than trusted. What a clear |
| | | * drops is the table a row records, dropped by that name, so a row recording something outside the |
| | | * namespace this backend names its tables in points at a table that is nobody's business of this |
| | | * one's - and a row naming no tree at all, or naming one that is not a tree name, would otherwise |
| | | * fail every clear from here on rather than the one thing it describes. |
| | | * <p> |
| | | * Every row passed over is described into {@code skippedRows}, the warn above being addressed to |
| | | * whoever is reading the log at that moment and this to the account a clear gives of itself: such |
| | | * a row is a tree the clear cannot see, so what the row records is dropped by nothing - while the |
| | | * row itself goes with the catalog table it sits in, which the clear names last and drops. That is |
| | | * what makes the line the only surviving copy of what such a row said, and why it carries the |
| | | * recorded name. A reader with nobody to tell - a read of {@code dbtest}, or the one an enrolment makes |
| | | * - hands in a list of its own and lets it go, which is one allocation per read of a whole table |
| | | * and no convention to get wrong. |
| | | */ |
| | | Map<TreeName,String> readCatalogRows(Connection con, String catalogTable) throws SQLException { |
| | | return readCatalogRows(con, catalogTable, new ArrayList<>()); |
| | | } |
| | | |
| | | Map<TreeName,String> readCatalogRows(Connection con, String catalogTable, List<String> skippedRows) |
| | | throws SQLException { |
| | | final Map<TreeName,String> trees=new LinkedHashMap<>(); |
| | | // the rows are read inside the bound rather than from a live ResultSet: #882 took the |
| | | // executeResultSet() that returned one away, so a transfer cannot run with nothing bounding it |
| | | try (final PreparedStatement statement=con.prepareStatement("select k,v from "+catalogTable)) { |
| | | executeResultSet(statement, rs -> { |
| | | while (rs.next()) { |
| | | final byte[] key=rs.getBytes("k"); |
| | | if (key==null) { // no tree is named by a row with no key, and a clear must not fail over one |
| | | logger.warn(LocalizableMessage.raw("jdbc: table %s holds a row naming no tree at all: skipped", |
| | | catalogTable)); |
| | | skippedRows.add("a row naming no tree at all"); |
| | | continue; |
| | | } |
| | | final String name=new String(db2real(key), StandardCharsets.UTF_8); |
| | | final TreeName treeName; |
| | | try { |
| | | treeName=TreeName.valueOf(name); |
| | | } catch (RuntimeException e) { // reported rather than passed off as a backend with fewer trees |
| | | logger.warn(LocalizableMessage.raw("jdbc: table %s holds \"%s\", which is not the name of a tree: skipped", |
| | | catalogTable, name)); |
| | | skippedRows.add("\""+name+"\", which is not the name of a tree"); |
| | | continue; |
| | | } |
| | | final byte[] table=rs.getBytes("v"); |
| | | final String tableName=table==null || table.length==0 |
| | | ? readTableName(treeName) // a row of a version which recorded the name and not the table |
| | | : new String(table, StandardCharsets.UTF_8); |
| | | // The prefix and not the whole of the name: the table recorded is taken from the row |
| | | // rather than derived again so that a removal drops what was enrolled even if the naming |
| | | // of tables were ever to change, and every naming this backend could take up is inside |
| | | // the namespace it already scans for what a clear left standing. What the shape does have |
| | | // to rule out is anything that is not a bare identifier: this value is read back from a |
| | | // table and reaches a "drop table" that no driver will take a bind parameter for. |
| | | if (!isOwnTableName(tableName)) { |
| | | logger.warn(LocalizableMessage.raw("jdbc: table %s records tree %s at \"%s\", which is no table of this backend: skipped", |
| | | catalogTable, treeName, tableName)); |
| | | skippedRows.add(treeName+" at \""+tableName+"\", which is no table of this backend"); |
| | | continue; |
| | | } |
| | | trees.put(treeName, tableName); |
| | | } |
| | | return null; |
| | | }); |
| | | } |
| | | return trees; |
| | | } |
| | | |
| | | /** |
| | | * Whether a name read back from the catalog is one of this backend's tables: inside the namespace |
| | | * it names them in, and a bare identifier besides. A clear drops the table a row records, by that |
| | | * name, in a statement built by concatenation - the DDL of no engine here takes a bind parameter |
| | | * for it - so a row is trusted to name a table of this backend and nothing else. The existence |
| | | * lookup in front of the drop would answer no for most of what this rules out; it is not what |
| | | * makes it safe. |
| | | */ |
| | | static boolean isOwnTableName(String tableName) { |
| | | if (!tableName.toLowerCase(Locale.ROOT).startsWith("opendj")) { |
| | | return false; |
| | | } |
| | | for (int i=0;i<tableName.length();i++) { |
| | | final char c=tableName.charAt(i); |
| | | if (!(c>='a' && c<='z') && !(c>='A' && c<='Z') && !(c>='0' && c<='9') && c!='_' && c!='$') { |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | final class ImporterImpl implements Importer { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Hands the connection back to the pool and closes the stamp session, whatever went before. |
| | | * Returns the failure the caller is to report: the return rolls back, and the rollback |
| | | * fails on exactly the connection whose commit just did, so the commit stays the exception |
| | | * the caller sees and this one rides along with it instead of replacing it. |
| | | * Hands the connection back to the pool and closes the sessions the transaction opened |
| | | * beside it - the stamp one and the catalog one (#888), both outside the pool and neither |
| | | * outliving the import that opened it - whatever went before. Returns the failure the |
| | | * caller is to report: the return rolls back, and the rollback fails on exactly the |
| | | * connection whose commit just did, so the commit stays the exception the caller sees and |
| | | * this one rides along with it instead of replacing it. |
| | | */ |
| | | private SQLException releaseConnection(SQLException failure) { |
| | | try { |
| | |
| | | failure.addSuppressed(reported); |
| | | } |
| | | } finally { |
| | | txw.stampSession.close(); |
| | | try { |
| | | txw.stampSession.close(); |
| | | } finally { |
| | | txw.catalogSession.close(); |
| | | } |
| | | } |
| | | return failure; |
| | | } |
| | |
| | | import java.util.NoSuchElementException; |
| | | import java.util.Objects; |
| | | import java.util.Set; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | import org.forgerock.i18n.LocalizableMessage; |
| | | import org.forgerock.i18n.slf4j.LocalizedLogger; |
| | |
| | | { |
| | | private static final int IMPORT_DB_CACHE_SIZE = 32 * MB; |
| | | |
| | | private static final double MAX_SLEEP_ON_RETRY_MS = 50.0; |
| | | /** |
| | | * Number of attempts a {@link WriteableStorageImpl#write} makes before it propagates the conflict to the caller. |
| | | * <p> |
| | | * It is a budget of attempts and not of time, so it is only ever reached by the conflicts that report quickly. |
| | | * PersistIt reports a write-write conflict only once it has waited on it, up to |
| | | * {@code SharedResource.DEFAULT_MAX_WAIT_TIME} - a minute, which this backend never lowers - so a conflict slower |
| | | * to report than {@link #MAX_RETRY_WINDOW_NANOS} spends the whole window inside its first attempt, is granted the |
| | | * single replay that window's exemption guarantees, and gives up on the window after two attempts rather than |
| | | * after this many. |
| | | */ |
| | | static final int MAX_RETRIES = 10; |
| | | |
| | | /** |
| | | * Wall-clock budget the replays of a {@link WriteableStorageImpl#write} may spend, in nanoseconds. It is checked |
| | | * between attempts, so an attempt already running is never interrupted, and never before one replay has been |
| | | * made: the loop returns after at most this window plus two attempts. It bounds the conflicts that are slow to |
| | | * report, which {@link #MAX_RETRIES} alone does not - an operation whose own work takes seconds would otherwise |
| | | * multiply that wait by the attempt count. |
| | | */ |
| | | static final long MAX_RETRY_WINDOW_NANOS = 10L * 1000L * 1000L * 1000L; //10 s |
| | | |
| | | /** |
| | | * Upper bound of the random delay before the second attempt, in milliseconds; it doubles with every attempt. This |
| | | * is the bound of the flat sleep this loop took before it was bounded, so the first replay is delayed exactly as |
| | | * it was and only the later ones back off. |
| | | */ |
| | | private static final double BASE_SLEEP_ON_RETRY_MS = 50.0; |
| | | |
| | | /** Upper bound the doubled delay is capped at, in milliseconds. */ |
| | | private static final double MAX_SLEEP_ON_RETRY_MS = 1000.0; |
| | | |
| | | private static final String VOLUME_NAME = "dj"; |
| | | private static final String JOURNAL_NAME = VOLUME_NAME + "_journal"; |
| | | /** The buffer / page size used by the PersistIt storage. */ |
| | |
| | | public void write(WriteOperation operation) throws Exception |
| | | { |
| | | final Transaction txn = db.getTransaction(); |
| | | for (;;) |
| | | final long startedAt = System.nanoTime(); |
| | | final long giveUpAt = startedAt + retryWindowNanos; |
| | | for (int attempt = 1;; attempt++) |
| | | { |
| | | final RollbackException conflict; |
| | | txn.begin(); |
| | | try |
| | | { |
| | |
| | | } |
| | | catch (final RollbackException e) |
| | | { |
| | | // retry after random sleep (reduces transactions collision. Drawback: increased latency) |
| | | Thread.sleep((long) (Math.random() * MAX_SLEEP_ON_RETRY_MS)); |
| | | conflict = e; |
| | | } |
| | | catch (final Exception e) |
| | | { |
| | |
| | | { |
| | | txn.end(); |
| | | } |
| | | // decided and slept for outside the try statement: the sleep used to run before the finally ended the |
| | | // rolled back transaction, holding it open for the whole backoff and lengthening the window every other |
| | | // writer collides with |
| | | //System.nanoTime() - giveUpAt is the overflow safe form of the comparison, and attempt > 1 keeps the |
| | | //window from ending the loop before a single replay: persistit reports a write-write conflict only once |
| | | //it has waited on it, up to SharedResource.DEFAULT_MAX_WAIT_TIME - a minute, which this backend never |
| | | //lowers - so one attempt can outlast the window on its own, and it is the attempt after that one which |
| | | //is likeliest to succeed, the transaction that blocked it having just finished |
| | | //one clock sample for both, so that the elapsed time reported is the one the give up was decided on |
| | | final long now = System.nanoTime(); |
| | | final boolean capSpent = attempt >= maxRetries; |
| | | if (capSpent || (attempt > 1 && now - giveUpAt >= 0)) |
| | | { |
| | | final long elapsedMs = TimeUnit.NANOSECONDS.toMillis(now - startedAt); |
| | | //which of the two bounds was spent, so that the config change paths - which report this as a bare stack |
| | | //trace with no message id - say whether raising the attempts or the window is what would have helped |
| | | final String boundSpent = capSpent ? "attempt cap" : "retry window"; |
| | | final StorageRuntimeException spent = new StorageRuntimeException( |
| | | "pdb: backend '" + config.getBackendId() + "' did not apply the transaction after " + attempt |
| | | + " attempts in " + elapsedMs + " ms, the " + boundSpent + " being spent; the last conflict was " |
| | | + conflict); |
| | | // the conflict is suppressed rather than made the cause, because a cause is what every caller strips |
| | | // this message off with: write(WriteOperation) below unwraps a StorageRuntimeException that carries one |
| | | // and throws the cause in its place, and EntryContainer.throwAllowedExceptionTypes:1121 rethrows a |
| | | // StorageRuntimeException unchanged only while getCause() is null, wrapping it a second time otherwise. |
| | | // Either way the caller would be left holding a bare RollbackException, whose StorageRuntimeException |
| | | // message is only its class name - which is all ERR_OPEN_ENV_FAIL would then print at startup |
| | | spent.addSuppressed(conflict); |
| | | //warned once, at exhaustion only, unlike JDBCStorage which warns on every replay: a conflict is routine |
| | | //on the ordinary add and modify path of this engine and a line per replay would flood the log. It names |
| | | //the bound that was spent for the same reason the exception does, and it is the only rendering that can |
| | | //carry the stack of the conflict: stackTraceToSingleLineString, the form the config change paths report |
| | | //this exception with, walks the causes and never prints a suppressed exception |
| | | logger.warn(LocalizableMessage.raw("pdb: giving up on the transaction of backend '%s' after %d attempts" |
| | | + " in %d ms, the %s being spent: %s", config.getBackendId(), attempt, elapsedMs, boundSpent, |
| | | stackTraceToSingleLineString(conflict))); |
| | | throw spent; |
| | | } |
| | | if (logger.isTraceEnabled()) |
| | | { |
| | | logger.trace("pdb: replaying the transaction after %s, attempt %d of %d", conflict, attempt, maxRetries); |
| | | } |
| | | try |
| | | { |
| | | // retry after random sleep (reduces transactions collision. Drawback: increased latency), growing with |
| | | // every attempt so that a contention the first delays did not outlast still has a chance to clear |
| | | Thread.sleep(retryDelayMillis(attempt)); |
| | | } |
| | | catch (final InterruptedException e) |
| | | { |
| | | //sleep cleared the interrupt flag: restore it, and report the conflict being replayed rather than the |
| | | //interrupt, which would hide from the caller what actually went wrong. Wrapped the way the exhausted |
| | | //loop above wraps it, and for the same reason: a RollbackException carries no message of its own, so |
| | | //every caller that wraps one reports nothing but its class name |
| | | Thread.currentThread().interrupt(); |
| | | final StorageRuntimeException interrupted = new StorageRuntimeException( |
| | | "pdb: backend '" + config.getBackendId() + "' was interrupted while replaying the transaction after " |
| | | + attempt + " attempts; the last conflict was " + conflict); |
| | | interrupted.addSuppressed(conflict); |
| | | interrupted.addSuppressed(e); |
| | | throw interrupted; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | private PDBMonitor monitor; |
| | | private MemoryQuota memQuota; |
| | | private StorageStatus storageStatus = StorageStatus.working(); |
| | | /** Attempt bound of a {@link WriteableStorageImpl#write}, {@link #MAX_RETRIES} outside the tests. */ |
| | | private final int maxRetries; |
| | | /** Wall-clock bound of a {@link WriteableStorageImpl#write}, {@link #MAX_RETRY_WINDOW_NANOS} outside the tests. */ |
| | | private final long retryWindowNanos; |
| | | |
| | | /** |
| | | * Creates a new persistit storage with the provided configuration. |
| | |
| | | // FIXME: should be package private once importer is decoupled. |
| | | public PDBStorage(final PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException |
| | | { |
| | | this(cfg, serverContext, MAX_RETRIES, MAX_RETRY_WINDOW_NANOS); |
| | | } |
| | | |
| | | /** |
| | | * Creates a new persistit storage whose replay bounds are the given ones rather than {@link #MAX_RETRIES} and |
| | | * {@link #MAX_RETRY_WINDOW_NANOS}. |
| | | * <p> |
| | | * Only a test builds one of these, and it does so to stop the two bounds racing each other: with the shipped |
| | | * values a run of replays spends a random share of the window on backoff alone, so a test of the attempt cap |
| | | * can be ended by the window on a loaded machine, and a test of the window has to spend seconds of build time |
| | | * to reach it. |
| | | * |
| | | * @param cfg |
| | | * The configuration. |
| | | * @param serverContext |
| | | * This server instance context |
| | | * @param maxRetries |
| | | * Number of attempts a write makes before it propagates the conflict to the caller. |
| | | * @param retryWindowNanos |
| | | * Wall-clock budget the replays of a write may spend, in nanoseconds. |
| | | * @throws ConfigException if memory cannot be reserved |
| | | */ |
| | | PDBStorage(final PDBBackendCfg cfg, ServerContext serverContext, int maxRetries, long retryWindowNanos) |
| | | throws ConfigException |
| | | { |
| | | this.serverContext = serverContext; |
| | | this.maxRetries = maxRetries; |
| | | this.retryWindowNanos = retryWindowNanos; |
| | | backendDirectory = getBackendDirectory(cfg); |
| | | config = cfg; |
| | | cfg.addPDBChangeListener(this); |
| | |
| | | return new ImporterImpl(); |
| | | } |
| | | |
| | | /** |
| | | * {@inheritDoc} |
| | | * <p> |
| | | * A transaction the engine rolled back is replayed, bounded twice: by {@link #MAX_RETRIES} attempts and by the |
| | | * {@link #MAX_RETRY_WINDOW_NANOS} wall-clock window, whichever is spent first - except that the window alone |
| | | * never ends the replays before one has been made. It is bounded because the |
| | | * configuration change paths of the pluggable backend hold an entry container's exclusive lock across this |
| | | * method, and every reader of that suffix then waits - untimed and uninterruptibly - until it returns, so a |
| | | * conflict that never clears would park every worker thread of that suffix rather than fail one operation. |
| | | * <p> |
| | | * Once the bound is spent the conflict is reported as a {@link StorageRuntimeException} naming the backend, the |
| | | * attempts spent, the time they took and which of the two bounds ran out. It carries the conflict as a |
| | | * suppressed exception rather than as its cause: a cause is unwrapped below and thrown in its place, and |
| | | * {@code EntryContainer.throwAllowedExceptionTypes} likewise passes a {@link StorageRuntimeException} through |
| | | * untouched only while it has no cause. Given a cause, both hand the caller a bare RollbackException instead, |
| | | * and the message of a {@link StorageRuntimeException} wrapping one is just its class name - which is all |
| | | * {@code ERR_OPEN_ENV_FAIL} would report when this happens as a backend starts. |
| | | */ |
| | | @Override |
| | | public void write(final WriteOperation operation) throws Exception |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | /** Returns the randomized delay before the given attempt is replayed, doubling with each attempt up to a cap. */ |
| | | //package private like the JDBCStorage copy it is taken from, so that a test can pin the growth and the cap |
| | | static long retryDelayMillis(int attempt) |
| | | { |
| | | final double bound = Math.min(MAX_SLEEP_ON_RETRY_MS, BASE_SLEEP_ON_RETRY_MS * (1 << Math.min(attempt - 1, 5))); |
| | | return (long) (Math.random() * bound); |
| | | } |
| | | |
| | | private Exception unwrap(StorageRuntimeException e) throws Exception |
| | | { |
| | | if (e.getCause() != null) |
| | |
| | | package org.opends.server.backends.pluggable; |
| | | |
| | | import static org.forgerock.util.Reject.*; |
| | | import static org.forgerock.util.Utils.closeSilently; |
| | | import static org.opends.messages.BackendMessages.*; |
| | | import static org.opends.server.util.ServerConstants.*; |
| | | import static org.opends.server.util.StaticUtils.*; |
| | | |
| | | import java.io.IOException; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.HashSet; |
| | | import java.util.List; |
| | |
| | | import org.opends.server.backends.pluggable.spi.Storage; |
| | | import org.opends.server.backends.pluggable.spi.StorageInUseException; |
| | | import org.opends.server.backends.pluggable.spi.StorageRuntimeException; |
| | | import org.opends.server.backends.pluggable.spi.TreeName; |
| | | 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.BackendConfigManager; |
| | | import org.opends.server.core.DeleteOperation; |
| | | import org.opends.server.core.DirectoryServer; |
| | | import org.opends.server.core.ModifyDNOperation; |
| | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * {@inheritDoc} |
| | | * <p> |
| | | * {@link Storage#write(WriteOperation)} replays its operation after a transaction conflict, so |
| | | * the operation below is confined to work a rollback undoes: the trees are deleted and opened |
| | | * there, while the registries, which no rollback reaches, are updated once the write has |
| | | * committed. Getting this the wrong way round leaves the change half applied, and its replay |
| | | * reports the missing half rather than the conflict that caused it. |
| | | * <p> |
| | | * What makes the operation replayable is that the base DNs to remove and to add are worked out |
| | | * once, ahead of the write, so that no attempt can see different work to do than the attempt it |
| | | * is replacing. |
| | | */ |
| | | @Override |
| | | public ConfigChangeResult applyConfigurationChange(final PluggableBackendCfg newCfg) |
| | | { |
| | | final ConfigChangeResult ccr = new ConfigChangeResult(); |
| | | try |
| | | // Read once: importLDIF, rebuildBackend, exportLDIF and verifyBackend all assign this field |
| | | // and null it out again, and this method now goes on using it past the commit. |
| | | final RootContainer rc = rootContainer; |
| | | if (rc == null) |
| | | { |
| | | if(rootContainer != null) |
| | | return ccr; |
| | | } |
| | | |
| | | final SortedSet<DN> newBaseDNs = newCfg.getBaseDN(); |
| | | // Ask the root container what this backend holds rather than the configuration it was last |
| | | // given: a base DN which an earlier, failed change left behind is work to do, and a |
| | | // configuration which was never applied is not. RootContainer.getBaseDNs() is a live view of |
| | | // the registered containers, so take a copy of it before anything registers one. |
| | | final Set<DN> currentBaseDNs = new HashSet<>(rc.getBaseDNs()); |
| | | final List<EntryContainer> deleted = new ArrayList<>(); |
| | | for (DN baseDN : currentBaseDNs) |
| | | { |
| | | if (!newBaseDNs.contains(baseDN)) |
| | | { |
| | | rootContainer.getStorage().write(new WriteOperation() |
| | | final EntryContainer ec = rc.getEntryContainer(baseDN); |
| | | // Answered exactly, never with an ancestor's container: getEntryContainer walks up the DN |
| | | // until it finds one, which is how an entry is routed to the base DN above it, and one |
| | | // backend holds hierarchically related base DNs whenever a registry which refused one left |
| | | // its container behind. Deleting the trees an ancestor answered with is deleting the trees |
| | | // of a base DN this backend is still serving. |
| | | if (ec == null || !baseDN.equals(ec.getBaseDN())) |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | SortedSet<DN> newBaseDNs = newCfg.getBaseDN(); |
| | | |
| | | // Check for changes to the base DNs. |
| | | removeDeletedBaseDNs(newBaseDNs, txn); |
| | | if (!createNewBaseDNs(newBaseDNs, ccr, txn)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | baseDNs = new HashSet<>(newBaseDNs); |
| | | |
| | | // Put the new configuration in place. |
| | | cfg = newCfg; |
| | | } |
| | | }); |
| | | // Unregistered since the copy above was taken, which is what closing the root container |
| | | // leaves behind: importLDIF, rebuildBackend and exportLDIF all do that, as does a backend |
| | | // being disabled. There is nothing to delete and nothing to say about the rest of the |
| | | // change, so none of it is attempted - and a result is returned rather than an exception, |
| | | // which is what the administration framework is owed whatever happens. |
| | | ccr.setResultCode(serverContext.getCoreConfigManager().getServerErrorResultCode()); |
| | | ccr.addMessage(ERR_BACKEND_BASEDN_NO_LONGER_HELD.get(getBackendID(), baseDN)); |
| | | return ccr; |
| | | } |
| | | deleted.add(ec); |
| | | } |
| | | } |
| | | catch (Exception e) |
| | | final List<DN> added = new ArrayList<>(); |
| | | for (DN baseDN : newBaseDNs) |
| | | { |
| | | ccr.setResultCode(serverContext.getCoreConfigManager().getServerErrorResultCode()); |
| | | ccr.addMessage(LocalizableMessage.raw(stackTraceToSingleLineString(e))); |
| | | if (!currentBaseDNs.contains(baseDN)) |
| | | { |
| | | added.add(baseDN); |
| | | } |
| | | } |
| | | if (deleted.isEmpty() && added.isEmpty()) |
| | | { |
| | | // The common case - index-entry-limit, db-cache-percent, preload-time-limit and the rest, |
| | | // which the entry containers apply through their own listeners. There is no storage work to |
| | | // do, so no transaction is opened to commit nothing. |
| | | baseDNs = new HashSet<>(newBaseDNs); |
| | | cfg = newCfg; |
| | | return ccr; |
| | | } |
| | | // Opened by the write operation, registered only once it has committed. |
| | | final List<EntryContainer> created = new ArrayList<>(); |
| | | try |
| | | { |
| | | try |
| | | { |
| | | changeBaseDNTrees(rc, deleted, added, created); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | logger.traceException(e); |
| | | |
| | | ccr.setResultCode(serverContext.getCoreConfigManager().getServerErrorResultCode()); |
| | | // The failure alone never says which base DNs the change was about, so name them. |
| | | // |
| | | // Only persistit and je roll the whole write back, leaving nothing at all applied and |
| | | // neither registry to touch. The jdbc backend does not, on any of its engines: its |
| | | // commitStatement() issues the statement and commits it, and commitsBeforeDdl() decides |
| | | // only which side of the statement the attempt stops being replayable on, never whether a |
| | | // completed "create table" or "drop table" survives the rollback of the write around it. |
| | | // Neither does cassandra, which has no transaction to roll back. |
| | | // giveUpBaseDNsWhoseTreesAreGone below reads what is actually left rather than trusting |
| | | // either answer. |
| | | ccr.addMessage(ERR_BACKEND_CANNOT_CHANGE_BASEDNS.get( |
| | | getBackendID(), baseDNsOf(deleted), added, stackTraceToSingleLineString(e))); |
| | | // Read before the entry containers are closed: what a container holds is what says which |
| | | // trees belong to it. |
| | | giveUpBaseDNsWhoseTreesAreGone(rc, deleted, ccr); |
| | | closeSilently(created); |
| | | return ccr; |
| | | } |
| | | |
| | | // The change is durable from here on, so every base DN is seen through even if one fails. |
| | | for (EntryContainer ec : deleted) |
| | | { |
| | | deregisterDeletedBaseDN(rc, ec, ccr); |
| | | } |
| | | registerNewBaseDNs(rc, created, ccr); |
| | | |
| | | // Put the new configuration in place. |
| | | cfg = newCfg; |
| | | } |
| | | finally |
| | | { |
| | | // What the root container ended up holding, not what was asked for: a base DN whose |
| | | // registration failed is not one this backend serves, and getBaseDNs() is what the monitors, |
| | | // isIndexed() and closeBackend() are answered from. Taken on the way out of every path which |
| | | // reached the write, the failed ones included, so that the two never disagree. The change |
| | | // which had no storage work to do sets it from the new configuration above; the one which |
| | | // found a base DN this backend no longer holds leaves it alone, since the root container it |
| | | // would be read from is being closed underneath it. |
| | | baseDNs = new HashSet<>(rc.getBaseDNs()); |
| | | } |
| | | return ccr; |
| | | } |
| | | |
| | | private void removeDeletedBaseDNs(SortedSet<DN> newBaseDNs, WriteableTransaction txn) throws DirectoryException |
| | | /** |
| | | * Deletes the trees of the base DNs being removed and opens the ones being added, as the single |
| | | * write operation a storage engine may replay. |
| | | * <p> |
| | | * The trees of a removed base DN are deleted while it is still registered, so its entry container |
| | | * is held exclusively for as long as the write runs, retries included, as |
| | | * {@link RootContainer#close()}, EntryContainer's index delete listener and AttributeIndex all do. |
| | | * That keeps out the operations which arrive during that window; an operation which had taken hold |
| | | * of the container before the lock still ends up in a closed one once it is released, as it did |
| | | * before this ordering. |
| | | * <p> |
| | | * The locks are given up with the write and are never held into the registry work which follows it. |
| | | * {@link BackendConfigManager} guards its registry with a single lock which the server already |
| | | * takes in the opposite order - {@code shutdownLocalBackends}, a backend being disabled and |
| | | * {@code applyConfigurationDelete} all hold it while finalizing a backend, which closes its root |
| | | * container and locks every entry container in turn. Holding the container lock into |
| | | * {@code deregisterBaseDN} would deadlock a base DN change against a shutdown, with no timeout on |
| | | * either side. |
| | | */ |
| | | private void changeBaseDNTrees(final RootContainer rc, final List<EntryContainer> deleted, |
| | | final List<DN> added, final List<EntryContainer> created) throws Exception |
| | | { |
| | | for (DN baseDN : cfg.getBaseDN()) |
| | | for (EntryContainer ec : deleted) |
| | | { |
| | | if (!newBaseDNs.contains(baseDN)) |
| | | // Taken outside the try, because EntryContainer.lock() has no throwing path - the write side |
| | | // of a ReentrantReadWriteLock, then a drain which swallows the interrupt - so every one of |
| | | // them is held by the time it is entered, and unlocking what was never locked cannot happen. |
| | | ec.lock(); |
| | | } |
| | | try |
| | | { |
| | | rc.getStorage().write(new WriteOperation() |
| | | { |
| | | // The base DN was deleted. |
| | | serverContext.getBackendConfigManager().deregisterBaseDN(baseDN); |
| | | EntryContainer ec = rootContainer.unregisterEntryContainer(baseDN); |
| | | ec.close(); |
| | | ec.delete(txn); |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | // Give up what a previous, rolled back attempt had opened: its trees are gone, and its |
| | | // entry containers still hold the configuration listeners they registered. |
| | | closeSilently(created); |
| | | created.clear(); |
| | | |
| | | // Opening the added base DNs comes first, so that the failure this operation is most |
| | | // likely to meet is met while everything is still there to roll back to. Once a tree |
| | | // has been deleted, a storage engine which does not undo that has nothing to give |
| | | // back. |
| | | for (DN baseDN : added) |
| | | { |
| | | created.add(rc.openEntryContainer(baseDN, txn, AccessMode.READ_WRITE)); |
| | | } |
| | | for (EntryContainer ec : deleted) |
| | | { |
| | | ec.delete(txn); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | finally |
| | | { |
| | | for (EntryContainer ec : deleted) |
| | | { |
| | | ec.unlock(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private boolean createNewBaseDNs(Set<DN> newBaseDNs, ConfigChangeResult ccr, WriteableTransaction txn) |
| | | /** |
| | | * Gives up the base DNs whose trees the failed write took with it. There is anything to give up |
| | | * only on an engine which does not roll the write back whole: mysql and oracle commit their DDL of |
| | | * their own accord, cassandra has no transaction at all, and the jdbc backend commits after each |
| | | * statement on every engine it supports. A base DN kept registered without its trees answers every |
| | | * operation with a storage error, where its removal was meant to leave a plain "no such entry"; |
| | | * one whose trees the rollback put back is left exactly as it was. |
| | | * <p> |
| | | * The trees the same write created for a base DN which is not being added after all are left where |
| | | * they are, and this is the only place which could have taken them back. The configuration naming |
| | | * that base DN was stored before this listener was called - {@code |
| | | * ConfigurationHandler.replaceEntry} writes the entry, and only then notifies its change listeners |
| | | * - and the failure does not take it back, so {@link RootContainer#open} opens that base DN again |
| | | * from it the next time this backend is opened, adopting the trees which survived and creating the |
| | | * ones which did not. Deleting them here would take away the trees of a base DN the stored |
| | | * configuration still asks this backend to serve, and would reach only the base DNs whose opening |
| | | * succeeded anyway: one which failed while being opened never became an entry container, and |
| | | * nothing but its own trees names them. |
| | | */ |
| | | private void giveUpBaseDNsWhoseTreesAreGone(RootContainer rc, List<EntryContainer> deleted, ConfigChangeResult ccr) |
| | | { |
| | | for (DN baseDN : newBaseDNs) |
| | | if (deleted.isEmpty()) |
| | | { |
| | | if (!rootContainer.getBaseDNs().contains(baseDN)) |
| | | return; |
| | | } |
| | | final Set<TreeName> storedTrees; |
| | | try |
| | | { |
| | | storedTrees = rc.getStorage().listTrees(); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | // Nothing can be said about what survived, so nothing is given up on the strength of it - and |
| | | // that is the case where the failure itself says least about what this backend is left |
| | | // serving, so it is said outright rather than left to a bare admin action. |
| | | logger.traceException(e); |
| | | ccr.setAdminActionRequired(true); |
| | | ccr.addMessage(ERR_BACKEND_CANNOT_LIST_TREES_AFTER_BASEDN_CHANGE.get( |
| | | getBackendID(), stackTraceToSingleLineString(e))); |
| | | return; |
| | | } |
| | | for (EntryContainer ec : deleted) |
| | | { |
| | | if (!storedTrees.containsAll(treeNamesOf(ec))) |
| | | { |
| | | try |
| | | { |
| | | // The base DN was added. |
| | | EntryContainer ec = rootContainer.openEntryContainer(baseDN, txn, AccessMode.READ_WRITE); |
| | | rootContainer.registerEntryContainer(baseDN, ec); |
| | | serverContext.getBackendConfigManager().registerBaseDN(baseDN, this, false); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | logger.traceException(e); |
| | | ccr.setAdminActionRequired(true); |
| | | deregisterDeletedBaseDN(rc, ec, ccr); |
| | | } |
| | | } |
| | | } |
| | | |
| | | ccr.setResultCode(serverContext.getCoreConfigManager().getServerErrorResultCode()); |
| | | ccr.addMessage(ERR_BACKEND_CANNOT_REGISTER_BASEDN.get(baseDN, e)); |
| | | return false; |
| | | private static Set<TreeName> treeNamesOf(EntryContainer ec) |
| | | { |
| | | final Set<TreeName> names = new HashSet<>(); |
| | | for (Tree tree : ec.listTrees()) |
| | | { |
| | | names.add(tree.getName()); |
| | | } |
| | | return names; |
| | | } |
| | | |
| | | private void deregisterDeletedBaseDN(RootContainer rc, EntryContainer ec, ConfigChangeResult ccr) |
| | | { |
| | | final DN baseDN = ec.getBaseDN(); |
| | | final BackendConfigManager backendConfigManager = serverContext.getBackendConfigManager(); |
| | | try |
| | | { |
| | | backendConfigManager.deregisterBaseDN(baseDN); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | logger.traceException(e); |
| | | |
| | | if (backendConfigManager.getLocalBackendWithBaseDN(baseDN) == this) |
| | | { |
| | | // deregisterBaseDN puts its new registry in place only once it has succeeded, so this base |
| | | // DN is still routed here. Leave the entry container registered: closeBackend() reclaims a |
| | | // base DN through rootContainer.getBaseDNs(), and one taken out of there would stay claimed |
| | | // by a backend which no longer holds it until the server is restarted. That is the opposite |
| | | // of what deregisterBaseDNsWhoseTreesAreGone does, and for the opposite reason: there the |
| | | // registry has already stopped routing to the base DN, so keeping the container only leaves |
| | | // a storage error where a "no such entry" was meant to be, while here the registry is still |
| | | // routing to it and dropping the container is what would leave that error behind. |
| | | ccr.setResultCode(serverContext.getCoreConfigManager().getServerErrorResultCode()); |
| | | ccr.setAdminActionRequired(true); |
| | | ccr.addMessage(ERR_BACKEND_CANNOT_DEREGISTER_BASEDN.get(baseDN, stackTraceToSingleLineString(e))); |
| | | return; |
| | | } |
| | | // It is not registered here, which is what an earlier change whose registerBaseDN failed |
| | | // leaves behind. Nothing routes to it, so there is nothing to hold on to. |
| | | } |
| | | rc.unregisterEntryContainer(baseDN); |
| | | closeSilently(ec); |
| | | } |
| | | |
| | | private void registerNewBaseDNs(RootContainer rc, List<EntryContainer> created, ConfigChangeResult ccr) |
| | | { |
| | | for (EntryContainer ec : created) |
| | | { |
| | | final DN baseDN = ec.getBaseDN(); |
| | | boolean registered = false; |
| | | try |
| | | { |
| | | rc.registerEntryContainer(baseDN, ec); |
| | | registered = true; |
| | | serverContext.getBackendConfigManager().registerBaseDN(baseDN, this, false); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | logger.traceException(e); |
| | | |
| | | ccr.setResultCode(serverContext.getCoreConfigManager().getServerErrorResultCode()); |
| | | ccr.setAdminActionRequired(true); |
| | | ccr.addMessage(ERR_BACKEND_CANNOT_REGISTER_BASEDN.get(baseDN, stackTraceToSingleLineString(e))); |
| | | if (!registered) |
| | | { |
| | | // Nothing else can reclaim it: closeBackend() and RootContainer.close() both work from |
| | | // the registered containers, and this one keeps the configuration listeners its |
| | | // constructor registered for as long as it is alive. |
| | | closeSilently(ec); |
| | | } |
| | | } |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | private static List<DN> baseDNsOf(List<EntryContainer> entryContainers) |
| | | { |
| | | final List<DN> baseDNs = new ArrayList<>(entryContainers.size()); |
| | | for (EntryContainer ec : entryContainers) |
| | | { |
| | | baseDNs.add(ec.getBaseDN()); |
| | | } |
| | | return baseDNs; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * Delete this entry container from disk. The entry container should be |
| | | * closed before calling this method. |
| | | * Deletes this entry container from disk, that is, every tree {@link #listTrees()} enumerates. |
| | | * The entry container may be open or closed: the trees are taken from the attribute and VLV index |
| | | * maps, which {@link #close()} closes the indexes of but leaves populated, so the same set is |
| | | * deleted either way. A {@code close()} which cleared those maps would turn a call made after it |
| | | * into a partial deletion, silently. Either way the container is not to be used afterwards. |
| | | * |
| | | * @param txn a non null transaction |
| | | * @throws StorageRuntimeException If an error occurs while removing the entry container. |
| | |
| | | <T> T read(ReadOperation<T> readOperation) throws Exception; |
| | | |
| | | /** |
| | | * Executes a write operation. In case of a write operation rollback, implementations must ensure |
| | | * the write operation is retried until it succeeds. |
| | | * Executes a write operation. In case of a write operation rollback, implementations may replay the write |
| | | * operation rather than propagate the failure: a {@link WriteOperation} is required to be idempotent for |
| | | * exactly that reason. A replay must be bounded - by a number of attempts, by a window of time, or by both - |
| | | * so that a conflict which does not clear reaches the caller instead of being retried forever. The pluggable |
| | | * backend holds locks across this method, up to the exclusive lock of an entry container, and every thread |
| | | * waiting on one of those locks waits for as long as this method does. |
| | | * <p> |
| | | * A caller that mutates state around this method must handle that bound being spent. Removing an entry from an |
| | | * in-memory map before the write so that a replay still finds the work to do, or reading configuration back out |
| | | * of the operation once it returns, both assume the write is applied; when it is not, this method throws with |
| | | * that state already changed and the transaction not applied, and the caller is the only place that can reconcile |
| | | * the two. |
| | | * |
| | | * @param writeOperation |
| | | * the write operation to execute |
| | | * @throws Exception |
| | | * if a problem occurs with the underlying storage engine |
| | | * if a problem occurs with the underlying storage engine, including a conflict that outlasted the |
| | | * replays the implementation makes |
| | | */ |
| | | void write(WriteOperation writeOperation) throws Exception; |
| | | |
| | |
| | | public void publishReplicaOfflineMsg() |
| | | { |
| | | final CSN offlineCSN = pendingChanges.putReplicaOfflineMsg(); |
| | | dsrsShutdownSync.replicaOfflineMsgSent(getBaseDN(), offlineCSN); |
| | | if (offlineCSN != null) |
| | | { |
| | | /* |
| | | * Only a message which really was published is announced: the shutdown of a collocated |
| | | * replication server waits for it to be forwarded, and would spend the whole grace |
| | | * period waiting for one which never reached the wire. |
| | | */ |
| | | dsrsShutdownSync.replicaOfflineMsgSent(getBaseDN(), offlineCSN); |
| | | } |
| | | else if (logger.isTraceEnabled()) |
| | | { |
| | | logger.trace("Replica " + getServerId() + " of domain baseDN=" + getBaseDN() |
| | | + " could not announce itself offline: a change which is still in flight holds" |
| | | + " the message back, and " + pendingChanges.size() + " change(s) are pending"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | * Returns whether the provided result code reports a failure of this server rather |
| | | * than a change which can not be applied: the backend being offline or rebuilt |
| | | * (OPENDJ-49), or the storage failing to serve the operation. |
| | | * <p> |
| | | * Package private for the tests, which pin what it answers for every registered result |
| | | * code directly rather than through a running server. |
| | | * |
| | | * @param result the result code of a replayed operation |
| | | * @param serverErrorResultCode the result code this server puts on an internal error |
| | | * @return {@code true} if the operation failed on the server itself |
| | | */ |
| | | private static boolean isServerFailure(ResultCode result, ResultCode serverErrorResultCode) |
| | | @VisibleForTesting |
| | | static boolean isServerFailure(ResultCode result, ResultCode serverErrorResultCode) |
| | | { |
| | | /* |
| | | * The result code the server puts on an internal error is configurable and is not |
| | |
| | | return; |
| | | } |
| | | |
| | | enableService(); |
| | | sessionGeneration++; |
| | | |
| | | /* |
| | | * The flag is cleared before the session is started, where disable() sets it before |
| | | * stopping one: enableService() ends with startListenService(), so the listener it |
| | | * starts can list a delivery and hand it to a replay thread while this method is |
| | | * still running. A replay thread which reads a flag that still says "disabled" |
| | | * gives the change up at the top of its replay loop, and abandonReplay() does not |
| | | * ask for it again - a domain on its way down owns its session - so the change is |
| | | * left listed, uncommitted and owned by nobody. Nothing would replay it: the |
| | | * replication server only sends it again over a session which is restarted, so this |
| | | * domain's ServerState, and every change which depends on that one, would be held |
| | | * back for as long as the session lives. |
| | | */ |
| | | disabled = false; |
| | | boolean started = false; |
| | | try |
| | | { |
| | | enableService(); |
| | | sessionGeneration++; |
| | | started = true; |
| | | } |
| | | finally |
| | | { |
| | | if (!started) |
| | | { |
| | | /* |
| | | * The other half of the same invariant: a domain whose session could not be |
| | | * started owns that session the way a disabled one does, so the flag goes back |
| | | * where it was rather than leave the replay threads believing there is a session |
| | | * of theirs to restart. |
| | | */ |
| | | disabled = true; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * Add a replica offline message to the pending list. |
| | | * Add a replica offline message to the pending list and publish it, if the changes which |
| | | * come before it have all been published. |
| | | * <p> |
| | | * 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 added |
| | | * @return the CSN of the message which was published, or {@code null} if it could not be |
| | | * published |
| | | */ |
| | | public synchronized CSN putReplicaOfflineMsg() |
| | | { |
| | |
| | | |
| | | pendingChanges.put(offlineCSN, pendingChange); |
| | | pushCommittedChanges(); |
| | | return offlineCSN; |
| | | // pushCommittedChanges() removes whatever it published, so the message is still listed |
| | | // here if and only if a change before it held it back. |
| | | final boolean heldBack = pendingChanges.remove(offlineCSN) != null; |
| | | return heldBack ? null : offlineCSN; |
| | | } |
| | | |
| | | /** |
| | |
| | | ERR_COMPSCHEMA_CANNOT_MIGRATE_619=The compressed schema definitions of backend '%s' could not be migrated from \ |
| | | the shared tree '%s' to '%s': %s. The backend cannot be opened, because its entries were encoded against the \ |
| | | definitions that were not migrated and would decode as the wrong attributes |
| | | ERR_BACKEND_CANNOT_DEREGISTER_BASEDN_620=An error occurred while attempting to deregister base DN %s \ |
| | | from the Directory Server: %s |
| | | ERR_BACKEND_CANNOT_CHANGE_BASEDNS_621=The base DNs of backend %s could not be changed (to remove: %s, \ |
| | | to add: %s): %s. A storage engine which rolls the whole write back leaves the backend exactly as it \ |
| | | was; on one which does not, the base DNs whose trees are gone have been given up. The base DNs being \ |
| | | added are not being served, but the configuration which has been stored still names them, so the next \ |
| | | time this backend is opened it opens them from that configuration, keeping whatever trees the failed \ |
| | | change created for them and creating the ones it did not. A base DN being removed of which only some \ |
| | | trees survived is given up with those trees still in the storage, where nothing names them afterwards; \ |
| | | removing them means re-creating the backend |
| | | ERR_BACKEND_CANNOT_LIST_TREES_AFTER_BASEDN_CHANGE_622=The base DN change of backend %s failed, and the \ |
| | | trees which survived it could not be listed: %s. No base DN has been given up on the strength of that, \ |
| | | so this backend may still be serving one whose trees are gone, which answers every operation against it \ |
| | | with a storage error |
| | | ERR_BACKEND_BASEDN_NO_LONGER_HELD_623=The base DNs of backend %s could not be changed: base DN %s is no \ |
| | | longer one this backend holds, which is what closing its root container leaves behind - an LDIF import, \ |
| | | an index rebuild, an LDIF export and the backend being disabled all do that. Nothing has been changed; \ |
| | | submit the change again once that has finished |
| | |
| | | /** The window as this JVM was started with it, put back after every test that varies it. */ |
| | | private static final long CONFIGURED_ALIVE_BYPASS_NANOS = CachedConnection.aliveBypassNanos; |
| | | |
| | | /** The standing read bound as this JVM was started with it, put back after every test that varies it. */ |
| | | private static final int CONFIGURED_READ_TIMEOUT_MILLIS = CachedConnection.readTimeoutMillis; |
| | | |
| | | @BeforeClass |
| | | public void registerStubDriver() throws Exception { |
| | | DriverManager.registerDriver(stub); |
| | |
| | | System.clearProperty(CachedConnection.POOL_MAX_PROPERTY); |
| | | System.clearProperty(CachedConnection.TTL_PROPERTY); |
| | | System.clearProperty(CachedConnection.ALIVE_BYPASS_PROPERTY); |
| | | System.clearProperty(CachedConnection.READ_TIMEOUT_PROPERTY); |
| | | // what has been reported once is remembered for the life of the jvm: left standing, the key |
| | | // of one test is what the next one finds when it asserts that it reported something itself |
| | | CachedConnection.warnedOnce.clear(); |
| | | CachedConnection.aliveBypassNanos = CONFIGURED_ALIVE_BYPASS_NANOS; |
| | | CachedConnection.readTimeoutMillis = CONFIGURED_READ_TIMEOUT_MILLIS; |
| | | } |
| | | |
| | | /** |
| | |
| | | assertEquals(CachedConnection.attemptSeconds(30, Long.MAX_VALUE), 30); |
| | | } |
| | | |
| | | /** |
| | | * A deadline so far off that naming it overflows is a wait with no end, and not one already |
| | | * behind us: a borrow configured to wait practically forever would otherwise give up on its |
| | | * first retryable failure, which is the opposite of what was asked for. The sum is what has to |
| | | * be guarded and not only the product - a value under the clamp of the product can still name a |
| | | * moment past the end of the epoch. |
| | | */ |
| | | @Test |
| | | public void testADeadlineTooFarOffToNameIsAWaitWithNoEnd() throws Exception { |
| | | final long startedAt = System.currentTimeMillis(); |
| | | // 0 is the operator asking for no deadline at all |
| | | assertEquals(CachedConnection.deadlineOf(startedAt, 0), Long.MAX_VALUE); |
| | | // ... and so is a value whose milliseconds would not fit a long at all |
| | | assertEquals(CachedConnection.deadlineOf(startedAt, Long.MAX_VALUE / 1000), Long.MAX_VALUE); |
| | | // the one the product guard lets through, which is the largest value it does: a second under |
| | | // the clamp, so the milliseconds of it still fit a long - by 1807 of them - while the moment |
| | | // they name, counted from now, does not. Guarded by the sum alone |
| | | assertEquals(CachedConnection.deadlineOf(startedAt, Long.MAX_VALUE / 1000 - 1), Long.MAX_VALUE); |
| | | // and an ordinary value still names the moment it says |
| | | assertEquals(CachedConnection.deadlineOf(startedAt, 60), startedAt + 60_000); |
| | | } |
| | | |
| | | /** The connection string holds the credentials of the backend: a stall report must not carry them. */ |
| | | @Test |
| | | public void testLoggedConnectionStringCarriesNoCredentials() throws Exception { |
| | |
| | | assertFalse(stall.contains("S3cret"), stall); |
| | | assertTrue(stall.contains("jdbc:postgresql://h:5432/db"), stall); |
| | | assertTrue(stall.contains("4000 ms") && stall.contains("(3 attempts)"), stall); |
| | | |
| | | // and so is the stall of a connect made outside the pool - the connection the tree catalog of a |
| | | // backend is written on (#888) - which is under the same rule and describes the same url |
| | | final String outside = CachedConnection.outsidePoolStallMessage(url, "tree catalog", 3, 4000, |
| | | new SQLException("FATAL: too many connections for " + url)); |
| | | assertFalse(outside.contains("S3cret"), outside); |
| | | assertTrue(outside.contains("jdbc:postgresql://h:5432/db"), outside); |
| | | assertTrue(outside.contains("4000 ms") && outside.contains("(3 attempts)"), outside); |
| | | assertTrue(outside.contains("tree catalog"), outside); |
| | | // and says what it is: a borrow of the pool is what this connect is not, and an operator |
| | | // reading it must not be sent to the pool for a stall the pool has no part in |
| | | assertFalse(outside.contains("pooled one"), outside); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * What a connection carries once the login is through, where a deployment asked for a read |
| | | * bound of its own: that bound rather than the bound of the login, which is a value nothing |
| | | * slower than a connect is meant to be measured against. Without one, this is the lift above - |
| | | * the behaviour of every connection this backend established before the property existed. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAnEstablishedConnectionCarriesTheReadBoundAskedFor() throws Exception { |
| | | final String url = StubDriver.PREFIX + "standing-read-bound"; |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final Connection parent = mock(Connection.class); |
| | | stub.answerWith(parent); |
| | | |
| | | CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 30, |
| | | CachedConnection.poolOf(url), false); |
| | | |
| | | verify(parent).setNetworkTimeout(any(Executor.class), eq(90000)); |
| | | verify(parent, never()).setNetworkTimeout(any(Executor.class), eq(0)); |
| | | } |
| | | |
| | | /** |
| | | * And it carries it whether or not the login had a bound of its own to lift. The read bound of |
| | | * a login is only ever set where the connect is bounded, so a deployment that runs with |
| | | * {@code connect.timeout=0} - the one setting that leaves a connect to the deadline of the |
| | | * borrow alone - would otherwise set this property and get nothing for it. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheReadBoundIsSetWhereTheLoginHadNoneToLift() throws Exception { |
| | | final String url = StubDriver.PREFIX + "read-bound-without-a-login-bound"; |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final Connection parent = mock(Connection.class); |
| | | stub.answerWith(parent); |
| | | |
| | | CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 0, |
| | | CachedConnection.poolOf(url), false); |
| | | |
| | | verify(parent).setNetworkTimeout(any(Executor.class), eq(90000)); |
| | | } |
| | | |
| | | /** |
| | | * A read bound standing in the connection string is the deployment's own: the connect does not |
| | | * replace it with this one, exactly as it does not set the read bound of a login on top of it. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAReadBoundOfTheUrlIsNotReplacedByTheConfiguredOne() throws Exception { |
| | | final String url = StubDriver.PREFIX + "own-read-bound?socketTimeout=1000"; |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final Connection parent = mock(Connection.class); |
| | | stub.answerWith(parent); |
| | | |
| | | CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 30, |
| | | CachedConnection.poolOf(url), false); |
| | | |
| | | verify(parent, never()).setNetworkTimeout(any(Executor.class), anyInt()); |
| | | } |
| | | |
| | | /** |
| | | * Which connections carry a read bound of this backend's own making, as the backstop of |
| | | * {@code JDBCStorage} has to know it: a statement of an unbounded class takes that bound off |
| | | * for as long as it runs, and it may only take off what this class put on. A bound of the url |
| | | * is the deployment's, and a driver whose property names are not known here was never given |
| | | * one - lifting either would leave the connection unbounded for the rest of its life. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testOnlyTheReadBoundThisClassSetsIsItsOwnToLift() { |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:mysql://localhost:3306/db"), 90000, |
| | | "the bound this class sets on a connection of a dialect it knows"); |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:mysql://localhost:3306/db?socketTimeout=1000"), 0, |
| | | "a read bound of the url was reported as this backend's own"); |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:h2:mem:db"), 0, |
| | | "a driver this class sets no read bound on was reported as bounded by it"); |
| | | |
| | | CachedConnection.readTimeoutMillis = 0; |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:mysql://localhost:3306/db"), 0, |
| | | "a connection carries no standing bound where none is configured"); |
| | | } |
| | | |
| | | /** |
| | | * The bound is configured in seconds and reaches the driver in milliseconds; 0, a negative |
| | | * value and a value that is no number all leave a connection unbounded, which is what this |
| | | * backend did before the property existed. A value past the ceiling of a socket read timeout is |
| | | * taken down to it rather than left to overflow the {@code int} of setNetworkTimeout, where it |
| | | * would arrive as a negative timeout - a value outside the contract, and one a driver is free |
| | | * to read as anything at all. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheReadBoundIsConfiguredInSeconds() { |
| | | assertEquals(CachedConnection.getReadTimeoutMillis(), 0, "a connection is unbounded by default"); |
| | | |
| | | System.setProperty(CachedConnection.READ_TIMEOUT_PROPERTY, "90"); |
| | | assertEquals(CachedConnection.getReadTimeoutMillis(), 90000); |
| | | |
| | | System.setProperty(CachedConnection.READ_TIMEOUT_PROPERTY, "0"); |
| | | assertEquals(CachedConnection.getReadTimeoutMillis(), 0); |
| | | |
| | | System.setProperty(CachedConnection.READ_TIMEOUT_PROPERTY, "-1"); |
| | | assertEquals(CachedConnection.getReadTimeoutMillis(), 0, "a negative value was not read as no bound"); |
| | | |
| | | System.setProperty(CachedConnection.READ_TIMEOUT_PROPERTY, "a minute and a half"); |
| | | assertEquals(CachedConnection.getReadTimeoutMillis(), 0, |
| | | "a value that is no number was not ignored in favour of the default"); |
| | | |
| | | System.setProperty(CachedConnection.READ_TIMEOUT_PROPERTY, Integer.toString(Integer.MAX_VALUE)); |
| | | assertEquals(CachedConnection.getReadTimeoutMillis(), JDBCStorage.MAX_BOUND_SECONDS * 1000, |
| | | "a value past the ceiling of a socket read timeout was not taken down to it"); |
| | | } |
| | | |
| | | /** |
| | | * A driver that will not take the standing bound leaves a connection with no bound of ours on |
| | | * it, which is what every connection of this pool carried before the property existed and no |
| | | * reason to keep this one out of the pool. The connection that must not be pooled is the one |
| | | * still carrying the read bound of its login: there the call that failed was a call to take |
| | | * something off, and the bound left on it fails every statement slower than a connect. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAConnectionThatWouldNotTakeTheStandingBoundIsStillPooled() throws Exception { |
| | | final String url = StubDriver.PREFIX + "unsettable-standing-bound"; |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final Connection parent = mock(Connection.class); |
| | | doThrow(new SQLException("setNetworkTimeout is not supported")) |
| | | .when(parent).setNetworkTimeout(any(Executor.class), anyInt()); |
| | | stub.answerWith(parent); |
| | | |
| | | final CachedConnection.Pool pool = CachedConnection.poolOf(url); |
| | | // Metered, and holding a permit of the pool as a borrow does: an unmetered connection is |
| | | // closed rather than pooled whatever bound it carries, which would answer this on the |
| | | // accounting of the pool instead of on the bound the case is about. |
| | | assertTrue(pool.tryReserve(), "the pool of this url would not reserve a place for the connection"); |
| | | final CachedConnection borrowed = CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 0, |
| | | pool, true); |
| | | borrowed.close(); |
| | | |
| | | verify(parent, never()).close(); |
| | | assertEquals(pool.idleCount(), 1, |
| | | "a connection carrying no bound of ours was kept out of the pool"); |
| | | } |
| | | |
| | | /** |
| | | * A read parameter of the url set to 0 is no bound of the deployment's: 0 is what every one of |
| | | * these drivers reads as "wait as long as it takes", which is the default this property exists |
| | | * to replace. It tells the two bounds apart, and only on postgresql, where a parameter of the |
| | | * url outranks the property this class supplies: the login there is left carrying no bound of |
| | | * ours, and rightly so, while the bound of this property is no property of a connect at all - it |
| | | * is a setNetworkTimeout of an established connection, which no url outranks. Read as a bound of |
| | | * theirs, a "socketTimeout=0" - the default of pgjdbc, written out - would leave a deployment |
| | | * that asked for this one with no bound and no report of why. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAReadParameterOfTheUrlSetToZeroIsNoBoundOfTheDeployments() { |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:postgresql://localhost/db?socketTimeout=0"), 90000, |
| | | "a postgresql url turning the read bound off was read as a bound of the deployment's own"); |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:mysql://localhost:3306/db?socketTimeout=0"), 90000, |
| | | "a mysql url turning the read bound off was read as a bound of the deployment's own"); |
| | | assertEquals(CachedConnection.standingReadBoundMillis("jdbc:postgresql://localhost/db?socketTimeout=30"), 0, |
| | | "a read bound of a postgresql url is the deployment's own and stands"); |
| | | } |
| | | |
| | | /** |
| | | * The predicate deciding whether a url bounds the read reads the same set of names as the one |
| | | * deciding whether it declares it. It used to stop at the first name present even where the |
| | | * value there was a zero, so a url naming the bound under both names of the oracle driver - the |
| | | * dotted one turned off, the last segment set - was declared() and not bounds(): the login kept |
| | | * the administrator's value, because a property of ours is not supplied over a declared one, and |
| | | * a bound of ours then went on top of it with setNetworkTimeout. Contrived, but "the bound taken |
| | | * off is the bound that was set" holds only while the two look at the same names. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAReadBoundUnderEitherNameOfTheUrlIsTheDeploymentsOwn() { |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | assertEquals(CachedConnection.standingReadBoundMillis( |
| | | "jdbc:oracle:thin:@//localhost:1521/db?oracle.jdbc.ReadTimeout=0&ReadTimeout=600"), 0, |
| | | "a bound standing under the last segment of the name was read as no bound at all"); |
| | | assertEquals(CachedConnection.standingReadBoundMillis( |
| | | "jdbc:oracle:thin:@//localhost:1521/db?oracle.jdbc.ReadTimeout=0&ReadTimeout=0"), 90000, |
| | | "a url turning the read bound off under both of its names is no bound of the deployment's"); |
| | | } |
| | | |
| | | /** |
| | | * With nothing configured this is the lift and nothing else - the read bound of the login comes |
| | | * off and no bound of ours goes on top of it, which is what every connection of this pool |
| | | * carried before the property existed. The default of this property is what makes the change |
| | | * that introduced it no change at all for a deployment that does not ask for one. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheDefaultTakesTheBoundOfTheLoginOffAndPutsNothingOnTopOfIt() throws Exception { |
| | | final String url = StubDriver.PREFIX + "default-read-bound"; |
| | | CachedConnection.readTimeoutMillis = 0; |
| | | final Connection parent = mock(Connection.class); |
| | | stub.answerWith(parent); |
| | | |
| | | CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 30, |
| | | CachedConnection.poolOf(url), false); |
| | | |
| | | verify(parent).setNetworkTimeout(any(Executor.class), eq(0)); |
| | | verify(parent, times(1)).setNetworkTimeout(any(Executor.class), anyInt()); |
| | | } |
| | | |
| | | /** |
| | | * A driver that would take neither the standing bound nor the lift leaves the connection that |
| | | * must not be pooled: what it is left carrying is the read bound of a connect, and every borrow |
| | | * after this one would meet it - which is the case above, reached by the other of the two paths |
| | | * that call for a setNetworkTimeout once the login is through. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAConnectionThatWouldTakeNeitherTheStandingBoundNorTheLiftIsNotPooled() throws Exception { |
| | | final String url = StubDriver.PREFIX + "unsettable-over-a-login-bound"; |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final Connection parent = mock(Connection.class); |
| | | doThrow(new SQLException("setNetworkTimeout is not supported")) |
| | | .when(parent).setNetworkTimeout(any(Executor.class), anyInt()); |
| | | stub.answerWith(parent); |
| | | |
| | | final CachedConnection.Pool pool = CachedConnection.poolOf(url); |
| | | // Metered, and holding a permit of the pool as a borrow does: an unmetered connection is |
| | | // closed rather than pooled whatever bound it carries, which would answer this on the |
| | | // accounting of the pool instead of on the bound the case is about. |
| | | assertTrue(pool.tryReserve(), "the pool of this url would not reserve a place for the connection"); |
| | | final CachedConnection borrowed = CachedConnection.connect(url, CachedConnection.ConnectDialect.MYSQL, 30, |
| | | pool, true); |
| | | borrowed.close(); |
| | | |
| | | verify(parent).close(); |
| | | assertEquals(pool.idleCount(), 0, |
| | | "a connection still carrying the read bound of its login went back into the pool"); |
| | | } |
| | | |
| | | /** |
| | | * The initializer reads this property the way it reads the two windows above - a value that is |
| | | * no number, or a negative one, is reported once and ignored in favour of the default - and |
| | | * reporting it has to leave the class usable: the set that report is deduplicated through is |
| | | * declared above every field whose initializer can reach it (JLS 12.4.2), so a field of this |
| | | * one moved above that set would turn a typo in a property into an ExceptionInInitializerError |
| | | * that no test of a class already initialized would ever meet. |
| | | */ |
| | | @Test(timeOut = 120000, dataProvider = "readBoundsWorthWarningAbout") |
| | | public void testAReadBoundWorthWarningAboutStillInitializesTheClass(String configured) throws Exception { |
| | | System.setProperty(CachedConnection.READ_TIMEOUT_PROPERTY, configured); |
| | | |
| | | final Class<?> reloaded = loadedAfresh(CachedConnection.class); |
| | | |
| | | assertNotSame(reloaded, CachedConnection.class, "the class under test was not loaded afresh"); |
| | | final Field field = reloaded.getDeclaredField("readTimeoutMillis"); |
| | | field.setAccessible(true); |
| | | assertEquals(field.getInt(null), 0, "the bound the reloaded class settled on"); |
| | | } |
| | | |
| | | @DataProvider |
| | | public Object[][] readBoundsWorthWarningAbout() { |
| | | return new Object[][]{{"a minute and a half"}, {"-1"}}; |
| | | } |
| | | |
| | | /** |
| | | * The case the window exists for: the connection this borrow takes out answered the database a |
| | | * moment ago, and asking it again costs the round trip the operation came to make. |
| | | */ |
| New file |
| | |
| | | /* |
| | | * 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 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.jdbc; |
| | | |
| | | import org.forgerock.opendj.server.config.server.JDBCBackendCfg; |
| | | import org.opends.server.DirectoryServerTestCase; |
| | | import org.testng.annotations.AfterClass; |
| | | import org.testng.annotations.BeforeClass; |
| | | import org.testng.annotations.BeforeMethod; |
| | | import org.testng.annotations.Test; |
| | | |
| | | import java.sql.Connection; |
| | | import java.sql.Driver; |
| | | import java.sql.DriverManager; |
| | | import java.sql.DriverPropertyInfo; |
| | | import java.sql.SQLException; |
| | | import java.sql.SQLFeatureNotSupportedException; |
| | | import java.sql.SQLTimeoutException; |
| | | import java.util.Properties; |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | |
| | | import static org.forgerock.opendj.config.ConfigurationMock.mockCfg; |
| | | import static org.mockito.Mockito.any; |
| | | import static org.mockito.Mockito.anyInt; |
| | | import static org.mockito.Mockito.doThrow; |
| | | import static org.mockito.Mockito.eq; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | import static org.testng.Assert.assertEquals; |
| | | import static org.testng.Assert.assertFalse; |
| | | import static org.testng.Assert.assertNotNull; |
| | | import static org.testng.Assert.assertTrue; |
| | | import static org.testng.Assert.fail; |
| | | |
| | | /** |
| | | * The connection the tree catalog of a backend is read and written on (#888): what it hands its |
| | | * driver, and what it does with the connection it gets back. It is established outside the pool - |
| | | * the caller of {@code openTree()} is holding a pooled connection already - so nothing the pool is |
| | | * asserted on covers it, and a container suite covers it only where a container starts: it skips |
| | | * itself whole otherwise, which leaves a bound nothing exercises. |
| | | * <p> |
| | | * No database is needed for any of it. The url is a postgresql one the pgjdbc driver cannot parse, |
| | | * so {@code DriverManager} falls through to the probe of this class, while {@code |
| | | * CachedConnection.ConnectDialect} still reads it as postgres - which is what makes the connect fill |
| | | * in bounds at all, and what a url of an engine of nobody's would not. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | public class CatalogConnectionTestCase extends DirectoryServerTestCase { |
| | | |
| | | /** |
| | | * What a caller with no replay above it hands the connect: the importer is the one such caller in |
| | | * the product, and every case here that is not about the window itself asks the way it asks, so |
| | | * that what it pins is the property and not a window of the test's own. |
| | | */ |
| | | private static final long NO_REPLAY_WINDOW = Long.MAX_VALUE; |
| | | |
| | | private ProbeDriver probeDriver; |
| | | |
| | | @BeforeClass |
| | | public void registerProbeDriver() throws SQLException { |
| | | probeDriver = new ProbeDriver(); |
| | | DriverManager.registerDriver(probeDriver); |
| | | } |
| | | |
| | | @AfterClass(alwaysRun = true) |
| | | public void deregisterProbeDriver() throws SQLException { |
| | | if (probeDriver != null) { |
| | | DriverManager.deregisterDriver(probeDriver); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Nothing of one case reaches the next: the probe is a field of the class and a case that fails |
| | | * before its finally would otherwise leave its stubbed connection, its refusals or the properties |
| | | * of its last attempt to be read by whatever runs after it. |
| | | */ |
| | | @BeforeMethod |
| | | public void resetProbe() { |
| | | probeDriver.lastProperties = null; |
| | | probeDriver.answer = null; |
| | | probeDriver.refusal = null; |
| | | probeDriver.refusalsLeft.set(0); |
| | | probeDriver.attempts.set(0); |
| | | probeDriver.refusalDelayMs = 0; |
| | | probeDriver.interruptOnAttempt = false; |
| | | } |
| | | |
| | | private static JDBCStorage storageFor(String url) { |
| | | final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class); |
| | | when(cfg.getBackendId()).thenReturn("catalogProbe"); |
| | | when(cfg.getDBDirectory()).thenReturn(url); |
| | | return new JDBCStorage(cfg, null); |
| | | } |
| | | |
| | | /** |
| | | * The bound of the connect is the one the operator configured for this backend's connects, and |
| | | * not a literal of the code: a deployment whose login legitimately takes longer than the default |
| | | * raises {@link CachedConnection#CONNECT_TIMEOUT_PROPERTY} for it, and a catalog connect bounded |
| | | * tighter than that fails in 08001 - which is no conflict a write replays, so the backend stops |
| | | * opening on an installation that opened before this connection existed. |
| | | * <p> |
| | | * Asked with no deadline over it, so that what the case pins is the configured bound alone: what |
| | | * a deadline does to it is the case below. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectTakesTheConfiguredBound() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "120"); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "0"); |
| | | try { |
| | | probeDriver.lastProperties = null; |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW).close(); |
| | | assertBoundedAt(probeDriver.lastProperties, 120); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * One attempt is never left to run past the deadline the retry of this connect is given, which is |
| | | * the deadline of a borrow ({@link CachedConnection#POOL_TIMEOUT_PROPERTY}): an attempt bounded |
| | | * looser than what is left of it would overrun it by a whole connect timeout. The pool bounds its |
| | | * own attempts by exactly this rule, and a connect established the way a pooled one is takes it. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectIsNeverBoundedPastTheDeadlineOfItsRetry() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "120"); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "20"); |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW).close(); |
| | | // a range and not the 20 exactly: the bound is what is left of the deadline when the attempt |
| | | // is made, so a pause of a second anywhere before it - a collection, the first touch of a |
| | | // class on a loaded box - makes it 19, and the case is about the deadline and not the clock |
| | | assertBoundedWithin(probeDriver.lastProperties, 15, 20); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A database taking no connection <em>for the moment</em> - at its connection limit, or still |
| | | * recovering - is waited out rather than reported: one attempt loses a race the borrow beside it |
| | | * wins, and this connect is on the critical path of the first read-write open of every backend. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectWaitsOutADatabaseTakingNoConnectionForTheMoment() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | // both, since both are read on every connect: an ambient connect bound would change the |
| | | // per-attempt bound these cases run under without changing anything they assert on |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "60"); |
| | | probeDriver.refusal = new SQLException("too many clients already", "53300"); |
| | | probeDriver.refusalsLeft.set(2); |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW).close(); |
| | | assertEquals(probeDriver.attempts.get(), 3, |
| | | "a connect refused for the moment was not retried the way a borrow of the pool retries it"); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * And everything else is the caller's to see rather than waited out behind its back: a password |
| | | * that is not accepted does not become a minute of silence and then the same failure. |
| | | * <p> |
| | | * A guard rather than a regression test, and worth saying so: the head before this connect had a |
| | | * retry made one attempt and reported it, so it satisfies this case by having no loop at all. |
| | | * What the case is here for is the loop that does exist staying this narrow - a predicate widened |
| | | * to any refusal turns a wrong password into a minute of silence per open, and nothing else in |
| | | * this suite would notice. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectDoesNotRetryAFailureThatWillNotClear() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | // both, since both are read on every connect: an ambient connect bound would change the |
| | | // per-attempt bound these cases run under without changing anything they assert on |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "60"); |
| | | probeDriver.refusal = new SQLException("password authentication failed", "28P01"); |
| | | probeDriver.refusalsLeft.set(Integer.MAX_VALUE); |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | fail("a connect that will not clear was retried instead of being reported"); |
| | | } catch (SQLException expected) { |
| | | assertEquals(expected.getSQLState(), "28P01", "the failure of the driver was not the one reported"); |
| | | assertEquals(probeDriver.attempts.get(), 1, "a failure that will not clear was attempted more than once"); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The wait ends at the deadline of a borrow, as a timeout by type and carrying the state of the |
| | | * driver's own last refusal: a state of this code's making would be read by {@code write()} as a |
| | | * connection the database dropped, and the retry must change no classification. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectGivesUpAtTheDeadlineOfABorrow() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | // both, since both are read on every connect: an ambient connect bound would change the |
| | | // per-attempt bound these cases run under without changing anything they assert on |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "1"); |
| | | // a vendor code beside the state, since both are carried over: an oracle failure says what it |
| | | // is in the ORA number and not in the SQLState, so a timeout dropping the code would answer 0 |
| | | // where the classifier reading it expects the driver's own |
| | | probeDriver.refusal = new SQLException("the database system is starting up", "57P03", 3113); |
| | | probeDriver.refusalsLeft.set(Integer.MAX_VALUE); |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | fail("a connect refused for the whole deadline was not given up on"); |
| | | } catch (SQLTimeoutException expected) { |
| | | // the state of the driver's own refusal and not one of this code's making: a manufactured |
| | | // 08001 is read by write() as a connection the database dropped, which would replay an |
| | | // attempt whose pooled connection is healthy and distrust the pool over it |
| | | assertEquals(expected.getSQLState(), "57P03", |
| | | "the failure the deadline ended carried another state than the driver's own"); |
| | | assertEquals(expected.getErrorCode(), 3113, |
| | | "the failure the deadline ended dropped the vendor code of the driver's own"); |
| | | assertNotNull(expected.getCause(), "the driver's own failure was not carried as the cause"); |
| | | assertTrue(probeDriver.attempts.get() > 1, |
| | | "the deadline was reached without the connect having been retried at all"); |
| | | // and it says which of the two bounds ran out, the property being the thing to raise only |
| | | // where the property is what ended the wait |
| | | assertTrue(expected.getMessage().contains(CachedConnection.POOL_TIMEOUT_PROPERTY + "=1s"), |
| | | "the timeout did not name the bound that ended it: " + expected.getMessage()); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The wait may not outlast the replay window of the {@code write()} it runs inside: this loop |
| | | * runs within one attempt of that one, so a refusal waited out past the window reaches it with |
| | | * the window already spent and is thrown unreplayed - the retry would cost the caller the replay |
| | | * it had before there was a retry here at all. The shorter of the two bounds is the deadline of |
| | | * the wait; the bound of one attempt is not taken from it, and this case pins that too. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectDoesNotOutlastTheReplayWindowOfItsCaller() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | // a minute, which is the default and six times the window of a write: the property is what |
| | | // this connect would wait out if the window did not reach it |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "60"); |
| | | probeDriver.refusal = new SQLException("the database system is starting up", "57P03"); |
| | | probeDriver.refusalsLeft.set(Integer.MAX_VALUE); |
| | | final long startedAt = System.currentTimeMillis(); |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(startedAt + 300); |
| | | fail("a connect refused past the replay window of its caller was not given up on"); |
| | | } catch (SQLTimeoutException expected) { |
| | | final long waited = System.currentTimeMillis() - startedAt; |
| | | assertTrue(waited < 30_000, |
| | | "the connect waited " + waited + " ms, which is the pool timeout rather than the window above it"); |
| | | assertTrue(probeDriver.attempts.get() > 1, |
| | | "the window was spent without the connect having been retried at all"); |
| | | assertEquals(expected.getSQLState(), "57P03", |
| | | "the failure the window ended carried another state than the driver's own"); |
| | | // the line has to send an operator to the right knob: raising the pool timeout moves |
| | | // nothing where the window of the write is the shorter bound |
| | | assertTrue(expected.getMessage().contains("replay window"), |
| | | "the timeout did not say which of the two bounds ended it: " + expected.getMessage()); |
| | | // and one attempt keeps the bound the operator configured for a login of this database: |
| | | // the window decides how long it is worth retrying, not how long a login may take, and |
| | | // an attempt cut to what is left of the window is the connect dying where the pooled one |
| | | // beside it succeeds - the backend that stops opening |
| | | assertBoundedAt(probeDriver.lastProperties, CachedConnection.DEFAULT_CONNECT_TIMEOUT_SECONDS); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A thread asked to stop is not answered by sleeping out the rest of a pool timeout: the flag is |
| | | * put back - every frame above this one reads it to decide whether to unwind - and the driver's |
| | | * own refusal is what the caller is told, the interrupt riding along with it so that a connect |
| | | * cut short by a shutdown is not read off the log as a database refusing connections. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectReportsAnInterruptRatherThanSleepingPastIt() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "60"); |
| | | probeDriver.refusal = new SQLException("the database system is starting up", "57P03"); |
| | | probeDriver.refusalsLeft.set(Integer.MAX_VALUE); |
| | | // raised inside the attempt rather than by another thread racing this one: the first backoff |
| | | // is a millisecond, and a sleep entered with the flag already up throws at once |
| | | probeDriver.interruptOnAttempt = true; |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | fail("a connect interrupted while it waited was not reported at all"); |
| | | } catch (SQLException expected) { |
| | | assertFalse(expected instanceof SQLTimeoutException, |
| | | "an interrupt was reported as the deadline of the wait running out"); |
| | | assertEquals(expected.getSQLState(), "57P03", |
| | | "the interrupt replaced the driver's own failure instead of riding along with it"); |
| | | assertEquals(probeDriver.attempts.get(), 1, "the wait went on past the interrupt"); |
| | | boolean carried = false; |
| | | for (final Throwable suppressed : expected.getSuppressed()) { |
| | | carried |= suppressed instanceof InterruptedException; |
| | | } |
| | | assertTrue(carried, "the interrupt was dropped rather than carried on the failure reported"); |
| | | assertTrue(Thread.currentThread().isInterrupted(), |
| | | "the flag Thread.sleep() cleared was not put back, so nothing above can read it"); |
| | | } finally { |
| | | Thread.interrupted(); // cleared here, or every case running after this one on this thread meets it |
| | | probeDriver.interruptOnAttempt = false; |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A connect of this backend that stalls says so in the log, and is throttled apart from the |
| | | * borrows of the pool that address the same database: the two stall for the same reason, and a |
| | | * borrow that reported a moment ago would otherwise silence the connect that is about to fail - |
| | | * which is the one of the two an operator has no other line about. |
| | | * <p> |
| | | * The stall is a real one rather than a call of the formatter: the guard of the throttle passes |
| | | * only where a connect has been retrying for a second, so nothing under it is reached by a case |
| | | * whose refusals come back at once. |
| | | */ |
| | | @Test |
| | | public void testAStallOfTheCatalogConnectIsThrottledApartFromTheBorrowsOfThePool() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "60"); |
| | | // one refusal, slow enough that the connect has been retrying for longer than the guard of |
| | | // the throttle, and then a connection: what is asserted is the warning, not the failure |
| | | probeDriver.refusal = new SQLException("the database system is starting up", "57P03"); |
| | | probeDriver.refusalsLeft.set(1); |
| | | probeDriver.refusalDelayMs = CachedConnection.STALL_WARNING_AFTER_MS + 100; |
| | | try { |
| | | // a url of its own: the throttle is keyed by url, and a case sharing one with another |
| | | // would read that one's stamp instead of its own |
| | | storageFor(ProbeDriver.STALL_URL).newCatalogConnection(NO_REPLAY_WINDOW).close(); |
| | | final long now = System.currentTimeMillis(); |
| | | final long longEnoughAgo = now - 2 * CachedConnection.STALL_WARNING_AFTER_MS; |
| | | // the connect reported its stall: the moment is filed, so the next one inside the interval |
| | | // is not due. Nothing else of this suite touches this url |
| | | assertFalse(CachedConnection.stallWarningDue(ProbeDriver.STALL_URL, "|tree catalog", longEnoughAgo, now), |
| | | "the connect stalled for longer than the guard and reported nothing"); |
| | | // and a borrow of the pool on that very url is still due one of its own, which is the half |
| | | // of the throttle key that keeps the two waits from silencing each other |
| | | assertTrue(CachedConnection.stallWarningDue(ProbeDriver.STALL_URL, "", longEnoughAgo, now), |
| | | "a stall of this connect silenced the borrows of the pool addressing the same database"); |
| | | } finally { |
| | | probeDriver.refusalDelayMs = 0; |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Nothing configured is the default of the pool, which is what this connect used to take always. |
| | | * The deadline is pinned rather than left to its own default: the attempt takes the shorter of |
| | | * the two, so a pool timeout set anywhere - the surefire configuration, the environment, another |
| | | * suite - would otherwise decide what this case asserts. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectTakesTheDefaultWhereNothingIsConfigured() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "0"); |
| | | try { |
| | | probeDriver.lastProperties = null; |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW).close(); |
| | | assertBoundedAt(probeDriver.lastProperties, CachedConnection.DEFAULT_CONNECT_TIMEOUT_SECONDS); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A property of 0 is the operator asking for no bound at all - the pool reads it that way - and a |
| | | * connect that bounded itself anyway would be answering a setting with the opposite of it. Nothing |
| | | * is handed to the driver then, and there is no read bound to lift once the login is through. |
| | | * <p> |
| | | * Both properties, which is what the pool itself says leaves a connect unbounded: the deadline of |
| | | * the retry bounds the attempt where there is one, so turning the per-attempt bound off alone |
| | | * leaves the attempt bounded by what is left of that deadline - the case above. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectIsUnboundedWhereTheOperatorTurnedTheBoundOff() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, "0"); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "0"); |
| | | try { |
| | | probeDriver.lastProperties = null; |
| | | final Connection con = storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | con.close(); |
| | | assertNotNull(probeDriver.lastProperties, "no properties were handed to the driver at all"); |
| | | assertTrue(probeDriver.lastProperties.isEmpty(), |
| | | "a connect the operator asked for no bound on was bounded anyway: " + probeDriver.lastProperties); |
| | | verify(con, never()).setNetworkTimeout(any(), anyInt()); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * What the connection is handed back for: rows of its own, committed where they are written. The |
| | | * read bound of the login is lifted as soon as the login is through (#872) - it is a bound of the |
| | | * connect and not of the statements of the catalog - and the isolation is the pool's, a repeatable |
| | | * read gap-locking a catalog two transactions enrol into. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectionIsSetUpForItsRows() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | // the read bound is lifted only where the attempt was given one, and the attempt takes the |
| | | // shorter of the two properties: a deadline of 0 elsewhere would leave nothing to lift here |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "0"); |
| | | try { |
| | | final Connection con = storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | con.close(); |
| | | verify(con).setAutoCommit(false); |
| | | verify(con).setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); |
| | | verify(con).setNetworkTimeout(any(), eq(0)); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A driver that will not take the read bound of the login back does not cost the backend its |
| | | * catalog: the pool meets the same failure and hands the connection to the borrower waiting for |
| | | * it, and a connect failing where the pooled one beside it succeeds is a backend that stops |
| | | * opening on an installation which opened before this connection existed. Reported, and kept. |
| | | * <p> |
| | | * A guard rather than a regression test, like the one above: the head before this round already |
| | | * caught that failure and returned the connection, so nothing of the round makes this case pass. |
| | | * It is here because the answer was reached for twice - once as "fail the connect", which this |
| | | * round took back out - and a third attempt at it would go unnoticed otherwise. |
| | | */ |
| | | @Test |
| | | public void testTheCatalogConnectionIsKeptWhereTheReadBoundWillNotComeOff() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "0"); |
| | | final Connection keeping = mock(Connection.class); |
| | | doThrow(new SQLFeatureNotSupportedException("no network timeout here")) |
| | | .when(keeping).setNetworkTimeout(any(), anyInt()); |
| | | probeDriver.answer = keeping; |
| | | try { |
| | | final Connection con = storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | assertNotNull(con, "a connection whose read bound would not come off was not handed back"); |
| | | verify(con).setAutoCommit(false); |
| | | verify(con, never()).close(); |
| | | } finally { |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A connection whose set-up failed is held by nobody - the caller is answered with the failure - |
| | | * so it is closed here or it leaks for the life of the process, one per open of a storage. |
| | | */ |
| | | @Test |
| | | public void testAConnectionWhoseSetUpFailsIsClosed() throws Exception { |
| | | final String previous = System.getProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | final String previousPool = System.getProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | // pinned like every other case of this class: both are read from the system properties on every |
| | | // connect, so an ambient value would have this case exercise another path than the one it names |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, "0"); |
| | | final Connection failing = mock(Connection.class); |
| | | doThrow(new SQLException("no transaction here", "08006")).when(failing).setAutoCommit(false); |
| | | probeDriver.answer = failing; |
| | | try { |
| | | storageFor(ProbeDriver.URL).newCatalogConnection(NO_REPLAY_WINDOW); |
| | | fail("a connection this backend could not set up was handed to the catalog"); |
| | | } catch (SQLException expected) { |
| | | // the failure of the set-up itself, reported to the caller rather than swallowed |
| | | } finally { |
| | | probeDriver.answer = null; |
| | | restore(previous); |
| | | restorePool(previousPool); |
| | | } |
| | | verify(failing).close(); |
| | | } |
| | | |
| | | /** What the dialect of this url declares, at the given number of seconds. */ |
| | | private static void assertBoundedAt(Properties handed, long seconds) { |
| | | assertNotNull(handed, "no properties were handed to the driver at all"); |
| | | final CachedConnection.ConnectDialect dialect = CachedConnection.ConnectDialect.of(ProbeDriver.URL); |
| | | assertNotNull(dialect, "the url of this test is read as an engine of nobody's, so it is bounded by nothing"); |
| | | for (final String property : dialect.connectProperties) { |
| | | assertEquals(handed.getProperty(property), Long.toString(seconds * dialect.connectUnitsPerSecond), |
| | | property + " did not reach the driver at the configured bound"); |
| | | } |
| | | assertEquals(handed.getProperty(dialect.readProperties[0]), |
| | | Long.toString(seconds * dialect.readUnitsPerSecond), |
| | | dialect.readProperties[0] + " did not reach the driver at the configured bound"); |
| | | } |
| | | |
| | | /** The same where the value is what is left of a deadline, which no case may pin to the millisecond. */ |
| | | private static void assertBoundedWithin(Properties handed, long atLeastSeconds, long atMostSeconds) { |
| | | assertNotNull(handed, "no properties were handed to the driver at all"); |
| | | final CachedConnection.ConnectDialect dialect = CachedConnection.ConnectDialect.of(ProbeDriver.URL); |
| | | assertNotNull(dialect, "the url of this test is read as an engine of nobody's, so it is bounded by nothing"); |
| | | final String property = dialect.connectProperties[0]; |
| | | final String handedValue = handed.getProperty(property); |
| | | assertNotNull(handedValue, property + " did not reach the driver at all"); |
| | | final long seconds = Long.parseLong(handedValue) / dialect.connectUnitsPerSecond; |
| | | assertTrue(seconds >= atLeastSeconds && seconds <= atMostSeconds, |
| | | property + " reached the driver at " + seconds + "s, outside the deadline it is taken from (" |
| | | + atLeastSeconds + ".." + atMostSeconds + "s)"); |
| | | } |
| | | |
| | | private static void restore(String previous) { |
| | | if (previous == null) { |
| | | System.clearProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY); |
| | | } else { |
| | | System.setProperty(CachedConnection.CONNECT_TIMEOUT_PROPERTY, previous); |
| | | } |
| | | } |
| | | |
| | | private static void restorePool(String previous) { |
| | | if (previous == null) { |
| | | System.clearProperty(CachedConnection.POOL_TIMEOUT_PROPERTY); |
| | | } else { |
| | | System.setProperty(CachedConnection.POOL_TIMEOUT_PROPERTY, previous); |
| | | } |
| | | } |
| | | |
| | | /** Records the properties a catalog connection hands its driver, and connects to nothing. */ |
| | | private static final class ProbeDriver implements Driver { |
| | | /** |
| | | * A postgresql url with a port that is not a number: pgjdbc cannot parse it and answers the |
| | | * DriverManager with null - or with a failure, which it records and walks past all the same - |
| | | * so this probe is the driver that ends up answering, while ConnectDialect still reads the |
| | | * prefix as postgres. |
| | | */ |
| | | static final String URL = "jdbc:postgresql://catalog-probe:not-a-port/db"; |
| | | |
| | | /** |
| | | * The same for the one case about the stall warning, which reads the throttle this connect |
| | | * files its report in: that throttle is keyed by url, so a case sharing one with any other |
| | | * would be asserting on whichever of them ran first. |
| | | */ |
| | | static final String STALL_URL = "jdbc:postgresql://catalog-probe-stall:not-a-port/db"; |
| | | |
| | | volatile Properties lastProperties; |
| | | |
| | | /** The connection to answer with, for a test about what is done with it; a fresh mock otherwise. */ |
| | | volatile Connection answer; |
| | | |
| | | /** How many attempts to refuse before answering, and with what; for the cases about the retry. */ |
| | | final AtomicInteger refusalsLeft = new AtomicInteger(); |
| | | volatile SQLException refusal; |
| | | |
| | | /** How long a refused attempt takes, for the one case that needs a wait the throttle counts. */ |
| | | volatile long refusalDelayMs; |
| | | |
| | | /** |
| | | * Whether an attempt raises the interrupt flag of the thread asking for it, so that the case |
| | | * about an interrupted wait does not have to race a backoff of one millisecond from outside. |
| | | */ |
| | | volatile boolean interruptOnAttempt; |
| | | |
| | | /** Every attempt this driver was asked to make, refused ones included. */ |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | |
| | | @Override |
| | | public Connection connect(String url, Properties info) throws SQLException { |
| | | if (!acceptsURL(url)) { |
| | | return null; // not ours: DriverManager goes on to the next driver |
| | | } |
| | | attempts.incrementAndGet(); |
| | | lastProperties = info; |
| | | if (refusal != null && refusalsLeft.getAndDecrement() > 0) { |
| | | if (refusalDelayMs > 0) { |
| | | try { |
| | | Thread.sleep(refusalDelayMs); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | } |
| | | if (interruptOnAttempt) { |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | throw refusal; |
| | | } |
| | | return answer != null ? answer : mock(Connection.class); |
| | | } |
| | | |
| | | @Override |
| | | public boolean acceptsURL(String url) { |
| | | return url != null && (url.startsWith(URL) || url.startsWith(STALL_URL)); |
| | | } |
| | | |
| | | @Override |
| | | public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { |
| | | return new DriverPropertyInfo[0]; |
| | | } |
| | | |
| | | @Override |
| | | public int getMajorVersion() { |
| | | return 1; |
| | | } |
| | | |
| | | @Override |
| | | public int getMinorVersion() { |
| | | return 0; |
| | | } |
| | | |
| | | @Override |
| | | public boolean jdbcCompliant() { |
| | | return false; |
| | | } |
| | | |
| | | @Override |
| | | public java.util.logging.Logger getParentLogger() throws SQLFeatureNotSupportedException { |
| | | throw new SQLFeatureNotSupportedException(); |
| | | } |
| | | } |
| | | } |
| | |
| | | import static org.mockito.Mockito.anyInt; |
| | | import static org.mockito.Mockito.anyString; |
| | | import static org.mockito.Mockito.atLeastOnce; |
| | | import static org.mockito.Mockito.doAnswer; |
| | | import static org.mockito.Mockito.doThrow; |
| | | import static org.mockito.Mockito.eq; |
| | | import static org.mockito.Mockito.inOrder; |
| | |
| | | System.clearProperty(bound.property); |
| | | } |
| | | System.clearProperty(JDBCStorage.STATISTICS_TIMEOUT_PROPERTY); |
| | | System.clearProperty(CachedConnection.READ_TIMEOUT_PROPERTY); |
| | | // a static of the pool rather than a property of this storage: left standing, the bound one |
| | | // test puts on its connections is the bound every test after it finds on them |
| | | CachedConnection.readTimeoutMillis = CONFIGURED_READ_TIMEOUT_MILLIS; |
| | | storage.accessMode = AccessMode.READ_ONLY; // an import test opens it for writing |
| | | } |
| | | |
| | | /** The standing read bound as this JVM was started with it, put back after every test that varies it. */ |
| | | private static final int CONFIGURED_READ_TIMEOUT_MILLIS = CachedConnection.readTimeoutMillis; |
| | | |
| | | /** |
| | | * A connection that keeps the read timeout it is given, the way a driver does. A mock answering |
| | | * a fixed {@code getNetworkTimeout()} cannot tell the two apart: a backstop that reads what the |
| | | * connection carried once and keeps it, and one that reads it again after having changed the |
| | | * value itself - which is how the standing bound of a connection is lost for the rest of its |
| | | * life in the pool. |
| | | */ |
| | | private static Connection connectionCarrying(int readTimeoutMillis) throws SQLException { |
| | | final Connection con = mock(Connection.class); |
| | | final AtomicInteger carried = new AtomicInteger(readTimeoutMillis); |
| | | when(con.getNetworkTimeout()).thenAnswer(new Answer<Integer>() { |
| | | @Override |
| | | public Integer answer(InvocationOnMock invocation) { |
| | | return carried.get(); |
| | | } |
| | | }); |
| | | doAnswer(new Answer<Void>() { |
| | | @Override |
| | | public Void answer(InvocationOnMock invocation) { |
| | | carried.set((Integer) invocation.getArguments()[1]); |
| | | return null; |
| | | } |
| | | }).when(con).setNetworkTimeout(any(Executor.class), anyInt()); |
| | | return con; |
| | | } |
| | | |
| | | /** How long a test waits for a statement running on another thread before it fails. */ |
| | | private static final long WAIT_MILLIS = 30000; |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * The other half of that, for the read bound a connection of this pool carries all its life: |
| | | * a statement of an unbounded class takes it off for as long as it runs. The socket read |
| | | * timeout of {@code CachedConnection.READ_TIMEOUT_PROPERTY} is armed at the login and never |
| | | * disarmed, so a count of a populated table or the delete that empties a tree before an import |
| | | * would die at it - and die naming no property at all, since a statement of an unbounded class |
| | | * has none in force to name. |
| | | */ |
| | | @Test |
| | | public void testABulkStatementTakesTheStandingReadBoundOffTheConnection() throws Exception { |
| | | CachedConnection.readTimeoutMillis = 90000; // as the login of this connection put it on |
| | | final Connection con = connectionCarrying(90000); |
| | | final PreparedStatement bulk = mock(PreparedStatement.class); |
| | | when(bulk.getConnection()).thenReturn(con); |
| | | when(bulk.executeUpdate()).thenReturn(1); |
| | | |
| | | storage.execute(bulk, StatementBound.BULK); |
| | | |
| | | final InOrder inOrder = inOrder(con); |
| | | inOrder.verify(con).setNetworkTimeout(any(Executor.class), eq(0)); |
| | | inOrder.verify(con).setNetworkTimeout(any(Executor.class), eq(90000)); |
| | | assertEquals(con.getNetworkTimeout(), 90000, "the connection was left without the bound it came with"); |
| | | } |
| | | |
| | | /** |
| | | * Only the bound this backend set is this backend's to take off. A read timeout standing in the |
| | | * connection string is the deployment's own - the connect leaves it alone rather than replacing |
| | | * it - and lifting it for a bulk statement would hand the connection back to the pool with the |
| | | * one bound its url asked for gone. |
| | | */ |
| | | @Test |
| | | public void testAReadBoundOfTheConnectionStringIsNotTakenOff() throws Exception { |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class); |
| | | when(cfg.getDBDirectory()).thenReturn("jdbc:postgresql://localhost/test?socketTimeout=600"); |
| | | final JDBCStorage bounded = new JDBCStorage(cfg, null); |
| | | final Connection con = connectionCarrying(600000); |
| | | final PreparedStatement bulk = mock(PreparedStatement.class); |
| | | when(bulk.getConnection()).thenReturn(con); |
| | | when(bulk.executeUpdate()).thenReturn(1); |
| | | |
| | | bounded.execute(bulk, StatementBound.BULK); |
| | | |
| | | verify(con, never()).setNetworkTimeout(any(Executor.class), anyInt()); |
| | | } |
| | | |
| | | /** |
| | | * A standing read bound at or under the bound of an ordinary statement is worth a word: the |
| | | * statement dies on the socket at it instead of being cancelled at the bound of its own class - |
| | | * which costs the connection the driver closes, and reports neither of the two properties that |
| | | * decided it. Above that bound the two compose, the cancel of the statement coming first and |
| | | * the standing bound staying behind it as the backstop of a cancel that is not acted upon. A |
| | | * class carrying no bound of its own is not cut by this at all: the bound comes off for as long |
| | | * as such a statement runs. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testAStandingReadBoundUnderTheBoundOfAStatementCutsItShort() { |
| | | final int backstop = (120 + JDBCStorage.BACKSTOP_MARGIN_SECONDS) * 1000; |
| | | assertTrue(JDBCStorage.cutsStatementsShort(60000, 120), "a bound under the bound of the statement"); |
| | | assertTrue(JDBCStorage.cutsStatementsShort(120000, 120), "a bound the statement reaches at the same moment"); |
| | | // Weighed against the socket layer of that bound, not against its cancel: the catalog lookups |
| | | // of openTree() are given no cancel at all, so what ends them is the layer a margin later - |
| | | // and a standing bound anywhere below that ends them earlier, with the backstop arming |
| | | // nothing on top of it because the connection already carries the tighter of the two. |
| | | assertTrue(JDBCStorage.cutsStatementsShort(140000, 120), |
| | | "a bound between the cancel of the statement and the socket layer behind it"); |
| | | assertTrue(JDBCStorage.cutsStatementsShort(backstop, 120), "a bound that layer reaches at the same moment"); |
| | | assertFalse(JDBCStorage.cutsStatementsShort(backstop + 1, 120), "a bound both layers come before"); |
| | | assertFalse(JDBCStorage.cutsStatementsShort(0, 120), "no standing bound at all"); |
| | | assertFalse(JDBCStorage.cutsStatementsShort(60000, 0), "a statement of an unbounded class, which is lifted"); |
| | | } |
| | | |
| | | /** |
| | | * What a standing read bound has to stand behind is the loosest bound a statement of this |
| | | * backend carries, not the bound of an ordinary one. The statistics refresh after an import has |
| | | * a property of its own - ten minutes by default, and it legitimately takes as long as a scan of |
| | | * the table it describes - so a standing bound of five cuts it on the socket, closing the |
| | | * importer's connection under a bare class-08 state naming neither property, and the statistics |
| | | * of #859 are then never refreshed. A bulk.timeout a deployment sets is in the same place: the |
| | | * class is no longer lifted, so its bound is weighed like any other. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheLoosestBoundOfAStatementIsWhatAStandingBoundHasToOutlive() { |
| | | assertEquals(JDBCStorage.loosestStatementBound().property, JDBCStorage.STATISTICS_TIMEOUT_PROPERTY, |
| | | "the statistics refresh is the loosest bound this backend gives a statement by default"); |
| | | assertEquals(JDBCStorage.loosestStatementBound().seconds, 600); |
| | | assertTrue(JDBCStorage.cutsStatementsShort(300000, JDBCStorage.loosestStatementBound().seconds), |
| | | "a standing bound of five minutes was not weighed against the ten of the statistics refresh"); |
| | | |
| | | System.setProperty(JDBCStorage.STATISTICS_TIMEOUT_PROPERTY, "0"); // the refresh left unbounded |
| | | assertEquals(JDBCStorage.loosestStatementBound().property, StatementBound.OPERATION.property); |
| | | assertEquals(JDBCStorage.loosestStatementBound().seconds, 120); |
| | | |
| | | System.setProperty(StatementBound.BULK.property, "3600"); // a class the lift no longer covers |
| | | assertEquals(JDBCStorage.loosestStatementBound().property, StatementBound.BULK.property); |
| | | assertEquals(JDBCStorage.loosestStatementBound().seconds, 3600); |
| | | } |
| | | |
| | | /** |
| | | * The bound follows the connection string the pool was registered with, the way every other path |
| | | * that names a pool does. db-directory may be changed on a running backend and the borrow still |
| | | * leaves the pool open() registered with, so a bound resolved against the url config names now |
| | | * would be the answer for a pool this storage never borrows from: a bulk statement of the |
| | | * registered one would find the lift gated off and die at a bound bulk.timeout=0 promises it will |
| | | * not meet, and the reverse pairing would lift a bound that is the deployment's own. |
| | | * <p> |
| | | * It is not resolved again after the change either. Read again while a lift is in flight, the |
| | | * answer of another url would send applyBackstop() to giveBack() and re-arm the bound under the |
| | | * statements the lift took it off for - both of them dying at it, and neither naming a property. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheStandingReadBoundFollowsTheUrlThePoolWasRegisteredWith() throws Exception { |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class); |
| | | when(cfg.getDBDirectory()).thenReturn("jdbc:mysql://registered/db"); |
| | | final JDBCStorage registered = openedOn(cfg); |
| | | try { |
| | | assertEquals(registered.standingReadBoundMillis(), 90000, "the bound of the url it registered with"); |
| | | |
| | | // the configuration changed under the running backend, to a url whose own read bound is |
| | | // the deployment's: the borrow still leaves the pool of the url above |
| | | when(cfg.getDBDirectory()).thenReturn("jdbc:mysql://changed/db?socketTimeout=600"); |
| | | registered.applyConfigurationChange(cfg); |
| | | |
| | | assertEquals(registered.standingReadBoundMillis(), 90000, |
| | | "the lift was decided against a pool this storage does not borrow from"); |
| | | } finally { |
| | | registered.close(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * And it is resolved while the backend opens, not at the first statement that needs it. |
| | | * applyBackstop() is the only place production asks, and it asks only behind a statement of a |
| | | * class carrying no bound of its own - a deployment that gives bulk.timeout a value of its own |
| | | * has no such statement anywhere, so the word owed to an operator whose two bounds are set the |
| | | * wrong way round would never be said at all. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheStandingReadBoundIsResolvedWhileTheBackendOpens() throws Exception { |
| | | System.setProperty(StatementBound.BULK.property, "3600"); // no statement of an unbounded class anywhere |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final JDBCBackendCfg cfg = mockCfg(JDBCBackendCfg.class); |
| | | when(cfg.getDBDirectory()).thenReturn("jdbc:mysql://resolved-at-open/db"); |
| | | final JDBCStorage opened = openedOn(cfg); |
| | | try { |
| | | // what the answer would be if it were resolved now, on the first statement to ask |
| | | CachedConnection.readTimeoutMillis = 37000; |
| | | |
| | | assertEquals(opened.standingReadBoundMillis(), 90000, |
| | | "the bound was not resolved while the backend opened"); |
| | | } finally { |
| | | opened.close(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A storage opened on a configuration, borrowing nothing from a database: open() registers the |
| | | * pool of the url - which costs no connect - and the validating borrow of the open is answered |
| | | * with a mock, so what is left is the registration this suite is about. |
| | | */ |
| | | private static JDBCStorage openedOn(JDBCBackendCfg cfg) throws Exception { |
| | | final Connection con = mock(Connection.class); |
| | | final JDBCStorage opening = new JDBCStorage(cfg, null) { |
| | | @Override |
| | | Connection getConnection(boolean trusted) { |
| | | return con; |
| | | } |
| | | }; |
| | | opening.open(AccessMode.READ_WRITE); |
| | | return opening; |
| | | } |
| | | |
| | | /** |
| | | * What the connection carried before is remembered across the lift, not read back off the |
| | | * connection while it is lifted: a bounded statement that outlives the bulk one takes the |
| | | * backstop of its own class, and the standing bound - not the zero of the lift - is what goes |
| | | * back when the last of them is through. Read again mid-flight, it would be the zero, and the |
| | | * connection would go back to the pool with no read bound at all for the rest of its life. |
| | | */ |
| | | @Test |
| | | public void testTheStandingReadBoundOutlivesTheLiftAndComesBackAfterIt() throws Exception { |
| | | System.setProperty(StatementBound.OPERATION.property, "7"); |
| | | CachedConnection.readTimeoutMillis = 90000; |
| | | final Connection con = connectionCarrying(90000); |
| | | final CountDownLatch bulkRunning = new CountDownLatch(1); |
| | | final CountDownLatch bulkMayFinish = new CountDownLatch(1); |
| | | final CountDownLatch operationRunning = new CountDownLatch(1); |
| | | final CountDownLatch operationMayFinish = new CountDownLatch(1); |
| | | final PreparedStatement bulk = lingering(con, bulkRunning, bulkMayFinish); |
| | | final PreparedStatement operation = lingering(con, operationRunning, operationMayFinish); |
| | | |
| | | final Background clearTree = start("clear-tree", () -> storage.execute(bulk, StatementBound.BULK)); |
| | | awaitOrFail(bulkRunning, "the bulk statement never started"); |
| | | final Background entryRead = start("entry-read", () -> storage.execute(operation)); |
| | | awaitOrFail(operationRunning, "the entry read never started"); |
| | | bulkMayFinish.countDown(); |
| | | clearTree.joinOrFail(); |
| | | operationMayFinish.countDown(); |
| | | entryRead.joinOrFail(); |
| | | |
| | | final InOrder inOrder = inOrder(con); |
| | | inOrder.verify(con).setNetworkTimeout(any(Executor.class), eq(0)); // the bulk statement takes it off |
| | | inOrder.verify(con).setNetworkTimeout(any(Executor.class), eq((7 + JDBCStorage.BACKSTOP_MARGIN_SECONDS) * 1000)); |
| | | inOrder.verify(con).setNetworkTimeout(any(Executor.class), eq(90000)); |
| | | assertEquals(con.getNetworkTimeout(), 90000, "the connection was left without the bound it came with"); |
| | | } |
| | | |
| | | /** |
| | | * The backstop belongs to the connection, not to the statement that armed it: the first |
| | | * statement to finish must not take it away from the statements still running there. |
| | | */ |
| | |
| | | import java.util.Properties; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | import java.util.concurrent.atomic.AtomicReference; |
| | | import java.util.function.Predicate; |
| | | import java.util.logging.Logger; |
| | | |
| | | import static org.forgerock.i18n.LocalizableMessage.raw; |
| | |
| | | import static org.mockito.Mockito.anyBoolean; |
| | | import static org.mockito.Mockito.anyInt; |
| | | import static org.mockito.Mockito.anyString; |
| | | import static org.mockito.Mockito.atLeastOnce; |
| | | import static org.mockito.Mockito.doNothing; |
| | | import static org.mockito.Mockito.doThrow; |
| | | import static org.mockito.Mockito.mock; |
| | |
| | | private Connection engineConnection; |
| | | |
| | | /** |
| | | * The connection the tree catalog of a storage of this test is written on, so that a test can assert what was |
| | | * written there. Nothing else can: it is opened straight through the driver rather than borrowed from the pool, |
| | | * and the rows it carries are the ones {@code statements} is asserted never to have carried. |
| | | */ |
| | | private Connection catalogConnection; |
| | | |
| | | /** |
| | | * Connections whose class names carry the engine the way the drivers' own do - pgjdbc's |
| | | * {@code org.postgresql.jdbc.PgConnection}, Connector/J's {@code com.mysql.cj.jdbc.ConnectionImpl}. That name |
| | | * is what {@code driverNameOf()} matches an engine on, and the name of a mock is derived from the type it |
| | |
| | | } |
| | | |
| | | /** |
| | | * The first read-write open of a backend upgraded from a version keeping no catalog creates the catalog and |
| | | * writes one row per tree, and creates no table of its own: every tree it opens is already there. None of that |
| | | * may commit anything of the caller's - {@code RootContainer.open()} opens every tree of every base DN in a |
| | | * single write, and a commit anywhere inside it takes the whole open out of the replay for the life of that |
| | | * attempt, so a deadlock at the twentieth tree would fail the backend start-up that master replayed. The rows |
| | | * still have to be committed, since nothing else of this open would carry them: a connection of the catalog's |
| | | * own is what makes the two compatible. |
| | | */ |
| | | @Test |
| | | public void testFillingTheCatalogOfAnUpgradedBackendLeavesTheAttemptReplayable() throws Exception |
| | | { |
| | | // every table of this backend is there except the one the catalog is kept in, which is the shape of an |
| | | // installation whose tables predate the catalog |
| | | final AtomicReference<String> catalogTable = new AtomicReference<>(); |
| | | final JDBCStorage storage = storageOverTablesThatAre(tableName -> !tableName.equals(catalogTable.get())); |
| | | catalogTable.set(storage.getTableName(storage.getCatalogTree())); |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | |
| | | storage.write(txn -> { |
| | | txn.openTree(TREE, true); |
| | | if (attempts.incrementAndGet() == 1) |
| | | { |
| | | throw new StorageRuntimeException(sql(0, "40001")); |
| | | } |
| | | }); |
| | | |
| | | assertEquals(attempts.get(), 2, "the write that created and filled the catalog was not replayed"); |
| | | verify(statements, never()).executeUpdate(); |
| | | // and the catalog was filled, on the connection of its own: without this every assertion above holds of a |
| | | // storage that enrolled nothing at all - an attempt issuing no statement is replayed the same way, and the |
| | | // caller's connection is exactly as untouched. The row is the ANSI upsert of a plain mock, which tries an |
| | | // update before an insert; the create is the table this fixture is missing |
| | | verify(catalogConnection, atLeastOnce()).prepareStatement(startsWith("create table " + catalogTable.get())); |
| | | verify(catalogConnection, atLeastOnce()).prepareStatement(startsWith("update " + catalogTable.get())); |
| | | // committed where it is written, which is what keeps a row of an attempt that failed afterwards recorded |
| | | verify(catalogConnection, atLeastOnce()).commit(); |
| | | } |
| | | |
| | | /** |
| | | * A tree that had to be created did commit - the create table commits, and mysql and oracle commit before a DDL |
| | | * statement of their own accord - so the attempt is out of the replay whatever the failure says: a |
| | | * {@link WriteOperation} is only idempotent in the database, and {@code RootContainer.open()} replayed after the |
| | |
| | | } |
| | | |
| | | /** |
| | | * A storage whose pool hands out one connection of this test, over a catalog that either holds the table of |
| | | * {@link #TREE} or does not. The connection is a mock of no recognized driver, which is how the engines that |
| | | * guard their create index - and mssql, which has none - reach {@code openTree}. |
| | | * A storage whose pool hands out one connection of this test, over a database that either holds the tables |
| | | * this backend asks about or holds none of them - the table of {@link #TREE} and the table of the tree |
| | | * catalog alike. The connection is a mock of no recognized driver, which is how the engines that guard their |
| | | * create index - and mssql, which has none - reach {@code openTree}. |
| | | */ |
| | | private JDBCStorage storageOverACatalogHolding(boolean theTable) throws Exception |
| | | { |
| | | return storageOverTablesThatAre(tableName -> theTable); |
| | | } |
| | | |
| | | /** |
| | | * The same, over a database holding exactly the tables the given rule accepts, and with a second connection |
| | | * behind the pooled one: the tree catalog is written on a connection of its own, so that its rows commit |
| | | * nothing of the caller's - which is the very thing {@code statements} is asserted on below. |
| | | */ |
| | | private JDBCStorage storageOverTablesThatAre(Predicate<String> present) throws Exception |
| | | { |
| | | final Connection con = mock(Connection.class); |
| | | final JDBCStorage storage = storageOver(con); |
| | | final JDBCStorage storage = storageOver(con, catalogConnection()); |
| | | |
| | | statements = mock(PreparedStatement.class); |
| | | final String tableName = storage.getTableName(TREE); |
| | | final DatabaseMetaData metaData = mock(DatabaseMetaData.class); |
| | | // a result set of its own per call: the catalog is asked once per attempt, and a replayed attempt |
| | | // reading a result set the previous one had already walked to its end would find no table there |
| | | when(metaData.getTables(any(), any(), any(), any())).thenAnswer(invocation -> { |
| | | // the name asked about and not the one table of a fixture: openTree() asks about the table of the tree |
| | | // and about the table of the catalog, and answering the second with the name of the first would have |
| | | // the catalog created over again on every attempt |
| | | final String asked = (String) invocation.getArguments()[2]; |
| | | final ResultSet tables = mock(ResultSet.class); |
| | | when(tables.next()).thenReturn(theTable, false); |
| | | when(tables.getString("TABLE_NAME")).thenReturn(tableName); |
| | | when(tables.next()).thenReturn(present.test(asked), false); |
| | | when(tables.getString("TABLE_NAME")).thenReturn(asked); |
| | | return tables; |
| | | }); |
| | | |
| | |
| | | return storage; |
| | | } |
| | | |
| | | /** A connection of no rows at all, for a query this fixture has nothing to answer with. */ |
| | | private static ResultSet noRows() throws SQLException |
| | | { |
| | | final ResultSet rs = mock(ResultSet.class); |
| | | when(rs.next()).thenReturn(false); |
| | | return rs; |
| | | } |
| | | |
| | | /** |
| | | * A storage whose pool hands out one connection of the given engine, over a catalog holding the table of |
| | | * {@link #TREE} and either holding its {@code k_} index or not. The index guard and the statement behind it |
| | | * The connection the tree catalog of a storage of this test is written on: it opens one straight through the |
| | | * driver, for the reason a stamp opens one of its own - the caller of openTree() is holding a pooled |
| | | * connection already. |
| | | */ |
| | | private Connection catalogConnection() throws Exception |
| | | { |
| | | final Connection con = mock(Connection.class); |
| | | final PreparedStatement onIt = mock(PreparedStatement.class); |
| | | final ResultSet empty = noRows(); // the read of what the catalog records: nothing was ever enrolled |
| | | when(onIt.executeQuery()).thenReturn(empty); |
| | | when(con.prepareStatement(anyString())).thenReturn(onIt); |
| | | // the stamp of a tree name opens a connection of its own too, and a fixture that let it have this one |
| | | // would have it issue the session statement of its dialect here |
| | | when(con.createStatement()).thenThrow(new SQLException("no session statement in this test", "42000")); |
| | | catalogConnection = con; |
| | | return con; |
| | | } |
| | | |
| | | /** |
| | | * A storage whose pool hands out one connection of the given engine, over a database holding every table |
| | | * this backend asks about - that of {@link #TREE} and that of its tree catalog - and either holding the |
| | | * {@code k_} index of the first or not. The index guard and the statement behind it |
| | | * are the branches {@code openTree()} takes per engine, and a mock of plain {@link Connection} reaches none |
| | | * of them - so the name the mock ends up with is asserted here rather than assumed. |
| | | */ |
| | |
| | | "a mock of " + engine.getSimpleName() + " reaches no " + engineName + " branch: " |
| | | + JDBCStorage.driverNameOf(con)); |
| | | engineConnection = con; |
| | | // the connections behind it answer the connects the pool does not make: the stamp of a tree name opens one |
| | | // of its own, straight through the driver, since the caller of openTree() is holding a pooled connection |
| | | final Connection[] answers = new Connection[behind.length + 1]; |
| | | // the connections behind it answer the connects the pool does not make: the tree catalog is read and |
| | | // written on one of its own, straight through the driver, since the caller of openTree() is holding a |
| | | // pooled connection already - and the stamp of a tree name opens one for the same reason. The catalog |
| | | // comes first because openTree() opens the catalog before anything else and stamping its table is the |
| | | // last thing that does, so a test naming a connection of its own names the one behind it |
| | | final Connection[] answers = new Connection[behind.length + 2]; |
| | | answers[0] = con; |
| | | System.arraycopy(behind, 0, answers, 1, behind.length); |
| | | answers[1] = catalogConnection(); |
| | | System.arraycopy(behind, 0, answers, 2, behind.length); |
| | | final JDBCStorage storage = storageOver(answers); |
| | | |
| | | statements = mock(PreparedStatement.class); |
| | |
| | | when(metaData.getTables(any(), any(), any(), any())).thenAnswer(invocation -> { |
| | | final ResultSet tables = mock(ResultSet.class); |
| | | when(tables.next()).thenReturn(true, false); |
| | | when(tables.getString("TABLE_NAME")).thenReturn(tableName); |
| | | // the name asked about: openTree() asks about the table of the tree and about the table of the catalog |
| | | when(tables.getString("TABLE_NAME")).thenReturn((String) invocation.getArguments()[2]); |
| | | return tables; |
| | | }); |
| | | when(metaData.getIndexInfo(any(), any(), any(), anyBoolean(), anyBoolean())).thenAnswer(invocation -> { |
| | |
| | | private JDBCStorage storageOver(Connection... connections) throws Exception |
| | | { |
| | | final JDBCBackendCfg cfg = mock(JDBCBackendCfg.class); |
| | | when(cfg.getDBDirectory()).thenReturn(StubDriver.PREFIX + pools.incrementAndGet()); |
| | | final int pool = pools.incrementAndGet(); |
| | | when(cfg.getDBDirectory()).thenReturn(StubDriver.PREFIX + pool); |
| | | // the tree catalog of a backend is named after its id: a mock answering null for it would name every |
| | | // storage of this class the same catalog, and the tables of these fixtures are named after that name |
| | | when(cfg.getBackendId()).thenReturn("retry" + pool); |
| | | final JDBCStorage storage = new JDBCStorage(cfg, null); |
| | | storage.accessMode = AccessMode.READ_WRITE; |
| | | stub.answerWith(connections); |
| | |
| | | * Header, with the fields enclosed by brackets [] replaced by your own identifying |
| | | * information: "Portions Copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2025 3A Systems, LLC. |
| | | * Copyright 2025-2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.jdbc; |
| | | |
| | | import org.opends.server.backends.pluggable.spi.AccessMode; |
| | | import org.opends.server.backends.pluggable.spi.TreeName; |
| | | import org.opends.server.backends.pluggable.spi.WriteOperation; |
| | | import org.opends.server.backends.pluggable.spi.WriteableTransaction; |
| | | import org.testcontainers.containers.JdbcDatabaseContainer; |
| | | import org.testcontainers.containers.PostgreSQLContainer; |
| | | import org.testng.annotations.Test; |
| | | |
| | | import java.sql.Connection; |
| | | import java.sql.DriverManager; |
| | | import java.sql.PreparedStatement; |
| | | import java.sql.ResultSet; |
| | | import java.sql.SQLException; |
| | | import java.sql.Statement; |
| | | |
| | | import static org.testng.Assert.assertEquals; |
| | | import static org.testng.Assert.assertFalse; |
| | | import static org.testng.Assert.assertNotEquals; |
| | | import static org.testng.Assert.assertTrue; |
| | | |
| | | //docker run --rm -it -p 5432:5432 -e POSTGRES_PASSWORD=password --name postgres postgres |
| | | |
| | | @Test |
| | | /** |
| | | * The class-level annotation governs the cases declared here, and it has to carry |
| | | * {@code sequential = true} of its own: {@code TestListener.enforceTestClassTypeAndAnnotations()} |
| | | * looks it up on the class declaring the case rather than on the one running it, so the |
| | | * {@code @Test(groups = ..., sequential = true)} of {@code PluggableBackendImplTestCase} answers for |
| | | * the inherited cases alone and a bare {@code @Test} here fails every case this class declares. |
| | | * {@code OracleTestCase} carries it for the same reason. |
| | | */ |
| | | @Test(sequential = true) |
| | | public class PgSqlTestCase extends TestCase { |
| | | |
| | | @Override |
| | |
| | | return "jdbc:postgresql://localhost:"+ ((container==null)?"5432":container.getMappedPort(5432))+"/database_name?user=postgres&password=password"; |
| | | } |
| | | |
| | | /** The schema put ahead of the one this suite's tables are in, for the case below and for nothing else. */ |
| | | private static final String AHEAD_ON_THE_PATH = "opendj_ahead"; |
| | | |
| | | /** |
| | | * A table of this backend is found where an unqualified statement of the same connection reaches it, and |
| | | * not only in the schema that connection happens to work in. |
| | | * <p> |
| | | * PostgreSQL resolves an unqualified reference across the whole {@code search_path} while an unqualified |
| | | * {@code create} lands in {@code current_schema()} alone, so the two are the same schema only as long as |
| | | * nothing was put in front of the one the tables were made in. Adding a schema of its own to a role is the |
| | | * standard remedy since PG15 took {@code CREATE} off {@code public}, and it makes them differ on an |
| | | * installation whose tables are already there: the backend goes on reading and writing them unqualified, |
| | | * and a lookup asking only about {@code current_schema()} would report every one of them absent. What that |
| | | * would cost is this issue over again - the clear would drop nothing and say nothing, which is #888 - and |
| | | * one thing worse besides: the next open would create a second, empty set of tables in the schema ahead, |
| | | * and from that commit on they would shadow the populated ones for every later unqualified reference. |
| | | * <p> |
| | | * The connection string carries the path rather than a role being altered, because the pools of this |
| | | * backend are keyed by it: a storage of another url is a storage of connections of its own, where an |
| | | * {@code ALTER ROLE} would leave every connection already pooled resolving the way it always did. |
| | | */ |
| | | @Test |
| | | public void testAClearFindsATableOfAnotherSchemaOfTheSearchPath() throws Exception { |
| | | final TreeName tree = new TreeName("testSearchPath", "tree"); |
| | | final String backendId = getBackendId() + "_searchPath"; |
| | | // the tables of an installation made before anything was put in front of the schema they are in |
| | | final JDBCStorage created = new JDBCStorage(createBackendCfg(backendId), null); |
| | | final String tableName = created.getTableName(tree); |
| | | // the same backend, over connections resolving in a schema of its own first and in the one the |
| | | // tables are in behind it: what they reach unqualified is unchanged, what they create is not |
| | | final String aheadOfThem = getJdbcUrl() + "¤tSchema=" + AHEAD_ON_THE_PATH + ",public"; |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(backendId, aheadOfThem), null); |
| | | try { |
| | | try { |
| | | created.open(AccessMode.READ_WRITE); |
| | | created.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | } finally { |
| | | created.close(); |
| | | } |
| | | assertTrue(isExistsTable(tableName), "the case did not make the table it is about"); |
| | | |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final Statement st = con.createStatement()) { |
| | | st.execute("create schema if not exists " + AHEAD_ON_THE_PATH); |
| | | } |
| | | storage.open(AccessMode.READ_WRITE); |
| | | try (final Connection con = DriverManager.getConnection(aheadOfThem)) { |
| | | // the fixture is the whole of the case: without this the two schemas are the same one and |
| | | // the assertions below hold of the version this case is about as well |
| | | assertEquals(con.getSchema(), AHEAD_ON_THE_PATH, |
| | | "the connections of this storage do not work in the schema put ahead of the tables"); |
| | | assertNotEquals(con.getSchema(), "public", "the tables of this case are not in public after all"); |
| | | } |
| | | |
| | | assertTrue(storage.listTrees().contains(tree), |
| | | "a tree whose table this connection reads unqualified was named by none of them"); |
| | | |
| | | // the other half of what the narrowing decides, and the destructive one: openTree() creates a |
| | | // table where its lookup answers that there is none, and an unqualified "create table" lands in |
| | | // current_schema() - the schema ahead of the tables. A lookup asking about that schema alone |
| | | // would answer no here and leave the populated table in public orphaned behind a second, empty |
| | | // one, from this commit on. The clear below drops what the catalog names and would go on |
| | | // passing while it happened, which is why this is asserted here rather than left to it |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | assertFalse(isExistsTableInSchema(AHEAD_ON_THE_PATH, tableName), |
| | | "the open created a second table in the schema ahead of the tables, shadowing the populated one"); |
| | | assertTrue(isExistsTableInSchema("public", tableName), |
| | | "the open did not leave the populated table where it is"); |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | assertFalse(isExistsTable(tableName), |
| | | "the clear left a table it reaches unqualified standing, for living in another schema of the search path"); |
| | | } finally { |
| | | // the same backend id, so this clears what either half of the case created - including the |
| | | // run where the clear under test drops nothing and the tables would otherwise be left for |
| | | // whatever case of this class runs next |
| | | clearQuietly(storage); |
| | | clearQuietly(new JDBCStorage(createBackendCfg(backendId), null)); |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final Statement st = con.createStatement()) { |
| | | st.execute("drop schema if exists " + AHEAD_ON_THE_PATH + " cascade"); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Whether the table is in that one schema, which is the question the case above asks and the one |
| | | * {@code TestCase.isExistsTable} cannot answer: it walks every schema the connection can see, so a |
| | | * table created in the wrong one of the two reads there exactly like a table created in the right |
| | | * one. Asked of {@code information_schema} with the schema and the name bound rather than through |
| | | * {@code getTables()}, whose schema is a pattern - {@code opendj_ahead} would match a schema named |
| | | * {@code opendjXahead} as readily, {@code _} being a single-character wildcard there. |
| | | */ |
| | | private boolean isExistsTableInSchema(String schema, String tableName) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final PreparedStatement st = con.prepareStatement( |
| | | "select 1 from information_schema.tables where table_schema=? and lower(table_name)=lower(?)")) { |
| | | st.setString(1, schema); |
| | | st.setString(2, tableName); |
| | | try (final ResultSet rs = st.executeQuery()) { |
| | | return rs.next(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | } |
| | |
| | | */ |
| | | package org.opends.server.backends.jdbc; |
| | | |
| | | import org.forgerock.i18n.LocalizableMessage; |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.opendj.ldap.ByteStringBuilder; |
| | | import org.forgerock.opendj.ldap.DN; |
| | | import org.forgerock.opendj.server.config.server.JDBCBackendCfg; |
| | | import org.opends.server.backends.pluggable.PluggableBackendImplTestCase; |
| | | import org.opends.server.backends.pluggable.spi.AccessMode; |
| | |
| | | import java.sql.ResultSet; |
| | | import java.sql.SQLException; |
| | | import java.sql.Statement; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.ArrayList; |
| | | import java.util.Collection; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.NoSuchElementException; |
| | | import java.util.Properties; |
| | | import java.util.Set; |
| | | import java.util.TreeSet; |
| | | import java.util.concurrent.Callable; |
| | | import java.util.concurrent.ExecutorService; |
| | | import java.util.concurrent.Executors; |
| | |
| | | |
| | | @Override |
| | | protected JDBCBackendCfg createBackendCfg() { |
| | | return createBackendCfg(getBackendId()); |
| | | } |
| | | |
| | | /** |
| | | * A configuration of another backend on the database of this suite: backends sharing one database |
| | | * URL is a configuration nothing forbids, and what one of them clears must be its own tables. |
| | | */ |
| | | protected JDBCBackendCfg createBackendCfg(String backendId) { |
| | | JDBCBackendCfg backendCfg = mockCfg(JDBCBackendCfg.class); |
| | | when(backendCfg.getBackendId()).thenReturn(getBackendId()); |
| | | when(backendCfg.getBackendId()).thenReturn(backendId); |
| | | when(backendCfg.getDBDirectory()).thenReturn(getJdbcUrl()); |
| | | return backendCfg; |
| | | } |
| | | |
| | | /** |
| | | * The same, reached over a connection string of the caller's own: the pools of this backend are |
| | | * keyed by it, so a case wanting connections established differently - in another schema of the |
| | | * search path, say - asks for them by asking for another url. |
| | | */ |
| | | protected JDBCBackendCfg createBackendCfg(String backendId, String jdbcUrl) { |
| | | final JDBCBackendCfg backendCfg = createBackendCfg(backendId); |
| | | when(backendCfg.getDBDirectory()).thenReturn(jdbcUrl); |
| | | return backendCfg; |
| | | } |
| | | |
| | | /** |
| | | * The same, serving the given base DN: what a clear compares the tree stamp of a table against |
| | | * when it says whether the table is this backend's own or another's (#866). |
| | | */ |
| | | protected JDBCBackendCfg createBackendCfg(String backendId, DN baseDN) { |
| | | final JDBCBackendCfg backendCfg = createBackendCfg(backendId); |
| | | final TreeSet<DN> baseDNs = new TreeSet<>(); |
| | | baseDNs.add(baseDN); |
| | | when(backendCfg.getBaseDN()).thenReturn(baseDNs); |
| | | return backendCfg; |
| | | } |
| | | |
| | | /** Asked of the database itself, by listing its tables, so that no folding rule of the backend is trusted here. */ |
| | | protected boolean isExistsTable(String tableName) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { |
| | | return isExistingTable(con, tableName); |
| | | } |
| | | } |
| | | |
| | | /** Drops a table behind the back of the storage that owns it, which no code path of the backend does. */ |
| | | private void dropTableBehindTheBackend(String tableName) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final Statement st = con.createStatement()) { |
| | | st.execute("drop table " + tableName); |
| | | } |
| | | } |
| | | |
| | | /** Clears a backend of a test without letting the failure of the clear replace the failure being reported. */ |
| | | protected static void clearQuietly(JDBCStorage storage) { |
| | | try { |
| | | storage.removeStorageFiles(); |
| | | } catch (Exception ignored) { |
| | | } finally { |
| | | storage.close(); |
| | | } |
| | | } |
| | | |
| | | @AfterClass |
| | | @Override |
| | | public void cleanUp() throws Exception { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * And the bound that replaces it reaches the socket of the driver: set with setNetworkTimeout |
| | | * once the login is through, it is read back off the connection the pool hands out (#885). The |
| | | * unit tests pin which value is set, on a mock that can only answer that it was asked; this is |
| | | * the driver of a real engine answering that it took it. |
| | | */ |
| | | @Test(timeOut = 120000) |
| | | public void testTheStandingReadBoundReachesTheSocket() throws Exception { |
| | | final String url = createBackendCfg().getDBDirectory(); |
| | | final int configured = CachedConnection.readTimeoutMillis; |
| | | CachedConnection.readTimeoutMillis = 5000; |
| | | try { |
| | | assertEquals(CachedConnection.standingReadBoundMillis(url), 5000, |
| | | "the url of this container carries a read bound of its own, so no bound of ours is set on it"); |
| | | // a pooled connection would be handed back without being established again |
| | | CachedConnection.poolOf(url).drainIdle(); |
| | | try (final Connection con = CachedConnection.getConnection(url)) { |
| | | assertEquals(con.getNetworkTimeout(), 5000, |
| | | "the read bound of this backend did not reach the socket of this driver"); |
| | | } |
| | | } finally { |
| | | CachedConnection.readTimeoutMillis = configured; |
| | | // and nothing carrying the bound of this test goes back to the pool the suite goes on using |
| | | CachedConnection.poolOf(url).drainIdle(); |
| | | } |
| | | } |
| | | |
| | | private static ByteString key(int i) { |
| | | return ByteString.valueOfUtf8(String.format("key%02d", i)); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * Reading a tree must not enrol it in the storage's tree map: removeStorageFiles() drops every |
| | | * table that map names, and the compressed schema reads the tree its definitions used to be |
| | | * shared under - which on a shared database is another backend's to keep (#873). Asking whether |
| | | * the tree is there is only the first of those reads: the migration counts it and copies it out |
| | | * too, so one guarded statement would not be enough. |
| | | * Reading a tree must not put it up for removal: a clear drops what the catalog of the backend |
| | | * names (#888), and the compressed schema reads the tree its definitions used to be shared under |
| | | * - which on a shared database is another backend's to keep (#873). Asking whether the tree is |
| | | * there is only the first of those reads: the migration counts it and copies it out too, so one |
| | | * guarded statement would not be enough. |
| | | * <p> |
| | | * The two storages are two backends and not one addressing the same database, which is what the |
| | | * case is about: what a backend owns is recorded in a catalog named after its backend id and |
| | | * outlives the process that opened the tree, so a second storage of the same id would be shown |
| | | * the tree its own earlier open had enrolled - and would be right to be. |
| | | */ |
| | | @Test |
| | | public void testProbingATreeDoesNotPutItUpForRemoval() throws Exception { |
| | |
| | | }); |
| | | owner.close(); |
| | | |
| | | // a second storage on the same database, which never opened that tree - the shape of two |
| | | // a second backend on the same database, which never opened that tree - the shape of two |
| | | // backends addressing one database |
| | | final JDBCStorage other = new JDBCStorage(createBackendCfg(), null); |
| | | final JDBCStorage other = new JDBCStorage(createBackendCfg(getBackendId() + "_probe"), null); |
| | | try { |
| | | other.open(AccessMode.READ_WRITE); |
| | | other.read(new ReadOperation<Void>() { |
| | |
| | | }); |
| | | owner.close(); |
| | | |
| | | // a storage that never opened that tree, so nothing but the delete can enrol it |
| | | // a second storage of the same backend, which never opened that tree itself: the delete takes |
| | | // the enrolling name and the table it writes to is the one the tree names. What puts a tree up |
| | | // for removal is the row its backend's catalog holds (#888) - written by the openTree above and |
| | | // outliving the storage that made it - so this asserts the listing of a backend and not a |
| | | // side effect of the statement, which is what a listing of a catalog can assert |
| | | final JDBCStorage other = new JDBCStorage(createBackendCfg(), null); |
| | | try { |
| | | other.open(AccessMode.READ_WRITE); |
| | |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.put(written, key(1), value(1)); // pending in this transaction... |
| | | // pending in this transaction, and it has to still be pending when the stamp is |
| | | // attempted: both trees were enrolled by the storage above, so the openTree below |
| | | // records nothing in the catalog and commits nothing of what is written here |
| | | txn.put(written, key(1), value(1)); |
| | | txn.openTree(stamped, true); // ...while the comment machinery fails |
| | | } |
| | | }); |
| | |
| | | return null; |
| | | } |
| | | }); |
| | | // the failure is remembered: an unstampable table is not asked again while this backend is open |
| | | // the failure is remembered: an unstampable table is not asked again while this backend is open. |
| | | // Counted from what the open itself attempted rather than from one: the open stamps the tree and |
| | | // the catalog of the backend, and how many tables an open has to stamp is not what this is about |
| | | final int attemptsOfTheOpen = stampAttempts.get(); |
| | | assertEquals(storage.commentTable(stamped, dialect()), JDBCStorage.CommentResult.FAILED); |
| | | assertEquals(stampAttempts.get(), 1, "a failed stamp was reissued"); |
| | | assertEquals(stampAttempts.get(), attemptsOfTheOpen, "a failed stamp was reissued"); |
| | | } finally { |
| | | try { |
| | | storage.write(new WriteOperation() { |
| | |
| | | storage.close(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * removeStorageFiles() has to clear a backend this process has never opened: offline import-ldif |
| | | * configures the backend and calls it before anything opens the root container, so answering from |
| | | * the trees this process happens to have touched dropped nothing at all - an offline |
| | | * "import-ldif --clearBackend" cleared a JDBC backend of nothing (#888). |
| | | */ |
| | | @Test |
| | | public void testABackendIsClearedByAProcessThatNeverOpenedIt() throws Exception { |
| | | final TreeName tree = new TreeName("testOfflineClear", "tree"); |
| | | // the neighbour serves a base DN of its own, so that its table is one it reports as its own: |
| | | // what this case asserts of the clear next door is then an absence and not a vacuity |
| | | final DN neighbourBaseDN = DN.valueOf("dc=offline-clear-neighbour,dc=com"); |
| | | final TreeName neighbourTree = new TreeName(neighbourBaseDN.toNormalizedUrlSafeString(), "tree"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_cleared"), null); |
| | | final JDBCStorage neighbour = |
| | | new JDBCStorage(createBackendCfg(getBackendId() + "_neighbour", neighbourBaseDN), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | txn.put(tree, key(1), value(1)); |
| | | } |
| | | }); |
| | | neighbour.open(AccessMode.READ_WRITE); |
| | | neighbour.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(neighbourTree, true); |
| | | txn.put(neighbourTree, key(1), value(1)); |
| | | } |
| | | }); |
| | | } catch (Exception e) { |
| | | // the clears of the case below are reached by no failure of this half, and nothing but |
| | | // @BeforeClass ever drops what it leaves behind |
| | | clearQuietly(storage); |
| | | clearQuietly(neighbour); |
| | | throw e; |
| | | } finally { |
| | | storage.close(); |
| | | neighbour.close(); |
| | | } |
| | | |
| | | // configured and never opened, nothing touched: what BackendImpl.importLDIF holds offline |
| | | final JDBCStorage offline = new JDBCStorage(createBackendCfg(getBackendId() + "_cleared"), null); |
| | | try { |
| | | assertTrue(offline.listTrees().contains(tree), |
| | | "the tree of a backend this process never opened has to be named by its catalog"); |
| | | |
| | | offline.removeStorageFiles(); |
| | | |
| | | assertFalse(isExistsTable(offline.getTableName(tree)), "the table of the tree survived the clear"); |
| | | assertFalse(isExistsTable(offline.getTableName(offline.getCatalogTree())), "the catalog survived the clear"); |
| | | final Set<TreeName> cleared = offline.listTrees(); |
| | | assertFalse(cleared.contains(tree), "a cleared backend still names its tree"); |
| | | assertFalse(cleared.contains(offline.getCatalogTree()), "a cleared backend still names its catalog"); |
| | | // the neighbour is named by a catalog of its own: what one backend clears is never another's |
| | | assertTrue(isExistsTable(neighbour.getTableName(neighbourTree)), |
| | | "the clear of one backend dropped the table of another backend of the same database"); |
| | | // nor does it report another backend's tables as tables of its own: a table is named after |
| | | // the hash of its tree name and says nothing about whose it is, but it is stamped with that |
| | | // tree name (#866), and the neighbour's trees are trees of no base DN this backend serves |
| | | assertReportsNothingOf(offline, neighbour, neighbourTree); |
| | | } finally { |
| | | // in a finally of their own: a failed assertion above must not leave the tables of either |
| | | // backend behind for the rest of the class, which nothing but @BeforeClass ever drops |
| | | clearQuietly(neighbour); |
| | | clearQuietly(offline); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A dropped tree has to leave the catalog together with its table: a row outliving its table |
| | | * would make backendstat name a tree that is not there, and would put a table that is already |
| | | * gone up for removal (#888). |
| | | */ |
| | | @Test |
| | | public void testADeletedTreeIsNoLongerNamedByTheCatalog() throws Exception { |
| | | final TreeName kept = new TreeName("testCatalogDelete", "kept"); |
| | | final TreeName dropped = new TreeName("testCatalogDelete", "dropped"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_deleted"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(kept, true); |
| | | txn.openTree(dropped, true); |
| | | } |
| | | }); |
| | | final Set<TreeName> opened = storage.listTrees(); |
| | | assertTrue(opened.contains(kept) && opened.contains(dropped), "an opened tree is not named by the catalog"); |
| | | |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.deleteTree(dropped); |
| | | } |
| | | }); |
| | | |
| | | final Set<TreeName> remaining = storage.listTrees(); |
| | | assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there"); |
| | | assertFalse(remaining.contains(dropped), "the catalog still names a tree that was deleted"); |
| | | // and the removal that follows must not stumble over the tree it no longer names |
| | | storage.removeStorageFiles(); |
| | | assertFalse(isExistsTable(storage.getTableName(kept)), "the table of the tree survived the clear"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | * from a backup older than the database, leaves exactly this (#888). |
| | | */ |
| | | @Test |
| | | public void testAClearSkipsACatalogRowWhoseTableIsGone() throws Exception { |
| | | final TreeName kept = new TreeName("testStaleCatalogRow", "kept"); |
| | | final TreeName vanished = new TreeName("testStaleCatalogRow", "vanished"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_stale"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(kept, true); |
| | | txn.openTree(vanished, true); |
| | | } |
| | | }); |
| | | dropTableBehindTheBackend(storage.getTableName(vanished)); |
| | | assertTrue(storage.listTrees().contains(vanished), |
| | | "the catalog was expected to go on naming the tree whose table was dropped behind its back"); |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | assertFalse(isExistsTable(storage.getTableName(kept)), |
| | | "a row of the catalog whose table is gone stopped the clear dropping the rest"); |
| | | assertFalse(isExistsTable(storage.getTableName(storage.getCatalogTree())), "the catalog survived the clear"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Naming a tree in order to read it must never put it up for removal: the tree read may be held |
| | | * by another backend of the same database, which nothing forbids (#873). Only |
| | | * openTree(createOnDemand) enrols. |
| | | */ |
| | | @Test |
| | | public void testReadingATreeDoesNotPutItUpForRemoval() throws Exception { |
| | | final TreeName owned = new TreeName("testReadDoesNotEnrol", "owned"); |
| | | final TreeName foreign = new TreeName("testReadDoesNotEnrolForeign", "tree"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_reader"), null); |
| | | final JDBCStorage owner = new JDBCStorage(createBackendCfg(getBackendId() + "_owner"), null); |
| | | try { |
| | | owner.open(AccessMode.READ_WRITE); |
| | | owner.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(foreign, true); |
| | | txn.put(foreign, key(1), value(1)); |
| | | } |
| | | }); |
| | | |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(owned, true); // the catalog of this backend comes into being here |
| | | txn.openTree(foreign, false); // read, not owned |
| | | } |
| | | }); |
| | | assertEquals(storage.read(new ReadOperation<ByteString>() { |
| | | @Override |
| | | public ByteString run(ReadableTransaction txn) throws Exception { |
| | | return txn.read(foreign, key(1)); |
| | | } |
| | | }), value(1), "the tree of the other backend could not be read"); |
| | | |
| | | assertFalse(storage.listTrees().contains(foreign), "reading a tree enrolled it in the catalog"); |
| | | storage.removeStorageFiles(); |
| | | assertTrue(isExistsTable(owner.getTableName(foreign)), |
| | | "the clear dropped a tree this backend had only read"); |
| | | assertFalse(isExistsTable(storage.getTableName(owned)), "the table of the backend's own tree survived the clear"); |
| | | } finally { |
| | | clearQuietly(owner); |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The compressed schema trees named from a literal carry no backend qualifier, so on a database |
| | | * addressed by several backends they are the same pair for all of them: a clear must leave them |
| | | * where they lie (#881). A tool asking a backend what trees it holds has to be shown them all the |
| | | * same, which is what keeps them out of the catalog and inside listTrees(). |
| | | */ |
| | | @Test |
| | | public void testTheSharedCompressedSchemaTreesAreNamedButNeverCleared() throws Exception { |
| | | final TreeName owned = new TreeName("testSharedCompressedSchema", "owned"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_schema"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(owned, true); |
| | | // opened, never written to: since #881 no backend of this class makes the literal-named |
| | | // pair, so this openTree is what creates these two tables - and the finally below is what |
| | | // removes them again, a clear being required to leave them exactly where they lie |
| | | for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) { |
| | | txn.openTree(shared, true); |
| | | } |
| | | } |
| | | }); |
| | | // both of them: the pair is a hand-copy of two privates of PersistentCompressedSchema, and |
| | | // a literal naming a tree that does not exist would go unseen if one of them were never asked |
| | | // for - the tree it names would be neither shown by listTrees() nor spared by a clear |
| | | final Set<TreeName> named = storage.listTrees(); |
| | | for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) { |
| | | assertTrue(named.contains(shared), |
| | | "a tool asking this backend for its trees was not shown " + shared); |
| | | } |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) { |
| | | assertTrue(isExistsTable(JDBCStorage.toTableName(shared)), |
| | | "the clear dropped " + shared + ", which another backend of this database may be the only owner of"); |
| | | } |
| | | assertFalse(isExistsTable(storage.getTableName(owned)), "the table of the backend's own tree survived the clear"); |
| | | } finally { |
| | | // the pair is dropped by hand here, and by nothing of the backend: a clear must leave it |
| | | // where it lies, which is the whole of what this case asserts. It is this case's to remove |
| | | // because it is this case that made it - since #881 each backend keeps its definitions in a |
| | | // pair of its own, so the literal-named pair belongs to no backend of this class any more |
| | | // and the openTree above is what created these two tables. Left standing they would be a |
| | | // legacy pair this database does not have, which |
| | | // testCompressedSchemaTableIsQualifiedByBackendId asserts about and TestNG may run after |
| | | // this case as easily as before it |
| | | clearQuietly(storage); |
| | | for (final TreeName shared : JDBCStorage.SHARED_COMPRESSED_SCHEMA_TREES) { |
| | | try { |
| | | dropTableBehindTheBackend(JDBCStorage.toTableName(shared)); |
| | | } catch (SQLException ignored) { // a case that failed before it made them leaves none to drop |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The row of a deleted tree must not be left to the enclosing transaction: a terminal failure |
| | | * later in it - write() replays a class 40 conflict and rethrows everything else - would roll the |
| | | * row back over a table that is already gone, and nothing would put it right, a deleted tree not |
| | | * being opened again (#888). |
| | | */ |
| | | @Test |
| | | public void testADeletedTreeStaysOutOfTheCatalogWhenItsTransactionFails() throws Exception { |
| | | final TreeName kept = new TreeName("testCatalogDeleteRollback", "kept"); |
| | | final TreeName deleted = new TreeName("testCatalogDeleteRollback", "deleted"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_rollback"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(kept, true); |
| | | txn.openTree(deleted, true); |
| | | } |
| | | }); |
| | | |
| | | try { |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.deleteTree(deleted); |
| | | // terminal, and no conflict for write() to replay: everything this transaction |
| | | // still owes goes back, and the row of the deleted tree must not be part of it |
| | | throw new IllegalStateException("the transaction of a deleteTree failed"); |
| | | } |
| | | }); |
| | | fail("the write was expected to fail"); |
| | | } catch (Exception expected) { |
| | | // what the case is about is what the failure left behind |
| | | } |
| | | |
| | | assertFalse(isExistsTable(storage.getTableName(deleted)), "the failed transaction brought a dropped table back"); |
| | | final Set<TreeName> remaining = storage.listTrees(); |
| | | assertFalse(remaining.contains(deleted), |
| | | "the catalog names a tree whose table the failed transaction left dropped"); |
| | | assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The same, for the branch a deleteTree takes when the table is not there any more: nothing is |
| | | * dropped, so there is no commit of a drop for the row to be carried out of the catalog by, and |
| | | * the commit the delete is given on the catalog's own connection is the whole of what takes it |
| | | * out. Left to the enclosing transaction, the row would go back with it and the catalog would name |
| | | * a tree with no table for good - the state a clear can only skip and report, never repair (#888). |
| | | */ |
| | | @Test |
| | | public void testADeletedTreeStaysOutOfTheCatalogWhenItsTableIsAlreadyGone() throws Exception { |
| | | final TreeName kept = new TreeName("testCatalogDeleteNoTable", "kept"); |
| | | final TreeName deleted = new TreeName("testCatalogDeleteNoTable", "deleted"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_noTable"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(kept, true); |
| | | txn.openTree(deleted, true); |
| | | } |
| | | }); |
| | | // what an interrupted change of an earlier run leaves: a row of the catalog naming a table |
| | | // that is not there any more. The deleteTree below therefore drops nothing at all |
| | | dropTableBehindTheBackend(storage.getTableName(deleted)); |
| | | |
| | | try { |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.deleteTree(deleted); |
| | | // terminal, and no conflict for write() to replay: everything this transaction |
| | | // still owes goes back, and the row of the deleted tree must not be part of it |
| | | throw new IllegalStateException("the transaction of a deleteTree failed"); |
| | | } |
| | | }); |
| | | fail("the write was expected to fail"); |
| | | } catch (Exception expected) { |
| | | // what the case is about is what the failure left behind |
| | | } |
| | | |
| | | final Set<TreeName> remaining = storage.listTrees(); |
| | | assertFalse(remaining.contains(deleted), |
| | | "the catalog names a tree whose table was already gone when it was deleted"); |
| | | assertTrue(remaining.contains(kept), "the catalog forgot a tree that is still there"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The row of a tree whose table is already standing must not be left to the enclosing transaction |
| | | * either. That is the open which fills the catalog of a backend upgraded from a version keeping |
| | | * none: it creates no table, so nothing else of openTree() commits anything, and a transaction |
| | | * failing after the enrolment would take the whole of it back - leaving a backend whose tables |
| | | * are named by no catalog and whose next clear therefore drops nothing at all (#888). |
| | | * <p> |
| | | * The row is written and committed on a connection of the catalog's own, so this holds on every |
| | | * engine for the same reason: nothing the caller's transaction does - or fails to do - reaches it. |
| | | * On the branch before this one the row rode the caller's connection, and the case was green on |
| | | * postgres for a reason of that engine alone (openTree() asks there for the cursor index of every |
| | | * tree on every open and commits that, carrying the row with it) while the other three lost it. |
| | | */ |
| | | @Test |
| | | public void testAReopenedTreeStaysInTheCatalogWhenItsTransactionFails() throws Exception { |
| | | final TreeName tree = new TreeName("testCatalogEnrolRollback", "tree"); |
| | | final JDBCStorage setUp = new JDBCStorage(createBackendCfg(getBackendId() + "_enrol"), null); |
| | | try { // the tables of the backend, made by a storage that then goes away |
| | | setUp.open(AccessMode.READ_WRITE); |
| | | setUp.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | } finally { |
| | | setUp.close(); |
| | | } |
| | | // and the rest of what an installation upgraded to a version keeping a catalog holds: a |
| | | // catalog naming none of those tables |
| | | emptyTheCatalog(setUp.getTableName(setUp.getCatalogTree())); |
| | | |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_enrol"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | assertFalse(storage.listTrees().contains(tree), "the catalog of the case was not emptied"); |
| | | |
| | | try { |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | // the table is there, so this open creates none: the enrolment is the only thing |
| | | // this transaction has written when it fails |
| | | txn.openTree(tree, true); |
| | | // terminal, and no conflict for write() to replay: everything this transaction |
| | | // still owes goes back, and the row naming a standing table must not be part of it |
| | | throw new IllegalStateException("the transaction of an openTree failed"); |
| | | } |
| | | }); |
| | | fail("the write was expected to fail"); |
| | | } catch (Exception expected) { |
| | | // what the case is about is what the failure left behind |
| | | } |
| | | |
| | | assertTrue(storage.listTrees().contains(tree), |
| | | "the catalog forgot a tree whose table is standing: a clear of this backend would drop nothing"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A tree the catalog already records at the table this version records it at is not enrolled |
| | | * again by an open: the row would be the row that is already there. A row recording any other |
| | | * table is, though - a removal drops the table the row records, so a row naming one this backend |
| | | * would not create leaves the real table standing, named by nothing and dropped by no clear ever |
| | | * after. Which of the two a row is has to be decided by what it records and not by its presence. |
| | | */ |
| | | @Test |
| | | public void testARowRecordingAnotherTableIsEnrolledAgain() throws Exception { |
| | | final TreeName tree = new TreeName("testCatalogStaleRow", "tree"); |
| | | final JDBCStorage setUp = new JDBCStorage(createBackendCfg(getBackendId() + "_staleRow"), null); |
| | | try { // the table and its row, by a storage that then goes away |
| | | setUp.open(AccessMode.READ_WRITE); |
| | | setUp.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | } finally { |
| | | setUp.close(); |
| | | } |
| | | final String catalogTable = setUp.getTableName(setUp.getCatalogTree()); |
| | | // what a version naming its tables otherwise would have left: a row of the right tree |
| | | // recording a table this one would never create |
| | | recordAnotherTable(catalogTable, "opendj_00000000000000000000000000000000000000000000000000000000"); |
| | | |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_staleRow"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { |
| | | assertEquals( |
| | | storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)).get(tree), |
| | | storage.getTableName(tree), |
| | | "a row recording a table this backend does not hold was left as it was: its tree is named at a table no clear can drop"); |
| | | } |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A clear which removed no tree of its backend says why, and says it where the one table it did |
| | | * drop was its own catalog: a catalog standing over rows that name nothing - the state a backup |
| | | * restored beside older tables leaves - is one drop and no tree removed, which is the outcome of |
| | | * #888 exactly and not a clear that did something. |
| | | * <p> |
| | | * Decided on the drops of trees and not on every drop for that reason. Counted the other way the |
| | | * line is silent here, since dropping the catalog makes the count one. |
| | | */ |
| | | @Test |
| | | public void testAClearWhichRemovedNoTreeSaysWhyEvenWhereItDroppedItsCatalog() throws Exception { |
| | | final DN baseDN = DN.valueOf("dc=clear-catalog-only,dc=com"); |
| | | final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry"); |
| | | final ReportingStorage storage = |
| | | new ReportingStorage(createBackendCfg(getBackendId() + "_catalogOnly", baseDN)); |
| | | final String catalogTable = storage.getTableName(storage.getCatalogTree()); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(owned, true); |
| | | } |
| | | }); |
| | | // the catalog table is there and names nothing, so the clear below has exactly one table to |
| | | // drop - its own - and leaves the tree standing, named by nothing |
| | | emptyTheCatalog(catalogTable); |
| | | storage.close(); |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | assertFalse(isExistsTable(catalogTable), "the clear left its own catalog table standing"); |
| | | assertTrue(isExistsTable(storage.getTableName(owned)), |
| | | "a table named by no catalog was dropped: nothing may be dropped that cannot be attributed"); |
| | | storage.assertReported("a clear which dropped its catalog and removed no tree of the backend" |
| | | + " said nothing about why, which is the silence of #888", |
| | | "removed no tree of this backend", "has to be started once"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | // left standing on purpose above: its catalog is gone, so no clear of this backend names it |
| | | dropTableIfExists(storage.getTableName(owned)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A row recording a name outside the namespace this backend names its tables in is passed over |
| | | * rather than reaching a {@code drop table} built from a value read back out of a table - and the |
| | | * clear accounts for it, no other line of its report being able to: what such a row records is |
| | | * outside the {@code opendj} names the scan of what a clear left standing walks, and is dropped |
| | | * by nothing. The row is not there to be read again either - the catalog names itself last, so |
| | | * the clear drops that table with the row still in it - which is why the line is asserted here |
| | | * along with the drop: it is the only surviving copy of what the row said. |
| | | * <p> |
| | | * Nothing this version writes makes such a row, which is why the case makes one by hand. |
| | | */ |
| | | @Test |
| | | public void testAClearAccountsForACatalogRowItCannotActOn() throws Exception { |
| | | final TreeName tree = new TreeName("testCatalogForeignRow", "tree"); |
| | | final ReportingStorage storage = new ReportingStorage(createBackendCfg(getBackendId() + "_foreignRow")); |
| | | final String tableName = storage.getTableName(tree); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | final String catalogTable = storage.getTableName(storage.getCatalogTree()); |
| | | recordAnotherTable(catalogTable, "a_table_of_something_else"); |
| | | |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { |
| | | final List<String> skipped = new ArrayList<>(); |
| | | assertFalse(storage.readCatalogRows(con, catalogTable, skipped).containsKey(tree), |
| | | "a row recording a name no table of this backend goes by was read as a tree to drop"); |
| | | assertEquals(skipped.size(), 1, "the row the read passed over was not described to its caller: " + skipped); |
| | | assertTrue(skipped.get(0).contains("a_table_of_something_else"), |
| | | "what the row records is named by nothing the clear could report: " + skipped); |
| | | } |
| | | |
| | | // the clear still drops what it can: the catalog itself, which it names last |
| | | storage.removeStorageFiles(); |
| | | assertTrue(isExistsTable(tableName), |
| | | "the clear dropped the table of a tree its catalog names at another name than that table's"); |
| | | assertFalse(isExistsTable(catalogTable), |
| | | "the clear left its own catalog table standing, so the row it passed over is still readable" |
| | | + " and the line reporting it is not the last copy of what it said"); |
| | | // the report itself and not the read behind it: reportSkippedRows() writes to nothing else, |
| | | // so both of its call sites could be deleted and every assertion above would still hold |
| | | storage.assertReported("the row the clear could not act on was reported by no line of it", |
| | | "a_table_of_something_else", "passed over"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | // left standing on purpose above, so this case removes it rather than the next one meeting it |
| | | dropTableIfExists(tableName); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A clear drops the table its catalog records for a tree, not one it derives again from the tree |
| | | * name, so that a removal drops what was enrolled even if the naming of tables were ever to |
| | | * change. A row recording no table at all - all a version recording the name alone would have |
| | | * left - falls back to the derived name rather than naming nothing. |
| | | */ |
| | | @Test |
| | | public void testAClearDropsTheTableTheCatalogRecords() throws Exception { |
| | | final TreeName tree = new TreeName("testCatalogValue", "tree"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_value"), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(tree, true); |
| | | } |
| | | }); |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { |
| | | // asked the way a clear asks it, narrowed to where an unqualified name of the connection |
| | | // resolves: what the removal reads is this and not a lookup of a shape of its own |
| | | final Map<TreeName, String> recorded = |
| | | storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)); |
| | | assertEquals(recorded.get(tree), storage.getTableName(tree), |
| | | "the catalog does not record the table holding the tree its row names"); |
| | | |
| | | emptyTheRecordedTableNames(storage.getTableName(storage.getCatalogTree())); |
| | | assertEquals(storage.catalogTables(con, JDBCStorage.TableScope.of(storage, con)).get(tree), |
| | | storage.getTableName(tree), |
| | | "a row recording no table name did not fall back to the name derived from the tree"); |
| | | } |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | assertFalse(isExistsTable(storage.getTableName(tree)), "the table the catalog named survived the clear"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A clear drops the catalog last, after every tree it names: what names the trees has to outlive |
| | | * them. Dropping a table is DDL, which mysql and oracle commit as they go, so a clear that fails |
| | | * halfway leaves a catalog still naming what is left - and the next attempt finishes it - where one |
| | | * that had dropped the catalog first would leave tables nothing names any more and no clear could |
| | | * ever reach. |
| | | * <p> |
| | | * Taken from the drops themselves and not from the map the loop walks: the map is built with the |
| | | * catalog put last by hand, so an assertion on it would hold of any loop at all - one that sorted |
| | | * the keys, or copied them into a HashSet, included. |
| | | */ |
| | | @Test |
| | | public void testAClearDropsTheCatalogAfterEveryTreeItNames() throws Exception { |
| | | final TreeName first = new TreeName("testCatalogDropOrder", "first"); |
| | | final TreeName second = new TreeName("testCatalogDropOrder", "second"); |
| | | final List<String> order = new ArrayList<>(); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_order"), null) { |
| | | @Override |
| | | void dropTable(Connection con, String tableName) throws SQLException { |
| | | order.add(tableName); |
| | | super.dropTable(con, tableName); |
| | | } |
| | | }; |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(first, true); |
| | | txn.openTree(second, true); |
| | | } |
| | | }); |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | final String catalogTable = storage.getTableName(storage.getCatalogTree()); |
| | | assertTrue(order.contains(storage.getTableName(first)) && order.contains(storage.getTableName(second)), |
| | | "the clear did not drop the tables of the trees its catalog names: " + order); |
| | | assertEquals(order.get(order.size() - 1), catalogTable, |
| | | "the clear dropped the catalog before a tree it names, which no later clear could reach: " + order); |
| | | assertEquals(order.indexOf(catalogTable), order.size() - 1, |
| | | "the catalog was dropped more than once: " + order); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * What a clear leaves standing it reports, and it reports it as what it is: a table stamped with a |
| | | * tree of a base DN this backend serves is its own and can be removed by hand, while a table of a |
| | | * backend sharing this database (#873) is that backend's business and no part of this outcome. |
| | | * Told apart by the stamp of #866 and by nothing else - a table name is a bare hash. |
| | | */ |
| | | @Test |
| | | public void testAClearReportsTheTablesItCanAttributeToThisBackend() throws Exception { |
| | | final DN baseDN = DN.valueOf("dc=clear-report,dc=com"); |
| | | final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry"); |
| | | // a base DN of its own, so that the neighbour is a backend that reports this table as its own: |
| | | // what this case asserts about the clear of the other one is then an absence and not a vacuity |
| | | final DN neighbourBaseDN = DN.valueOf("dc=clear-report-neighbour,dc=com"); |
| | | final TreeName neighbourTree = new TreeName(neighbourBaseDN.toNormalizedUrlSafeString(), "id2entry"); |
| | | final JDBCStorage storage = new JDBCStorage(createBackendCfg(getBackendId() + "_reported", baseDN), null); |
| | | final JDBCStorage neighbour = |
| | | new JDBCStorage(createBackendCfg(getBackendId() + "_reportedNeighbour", neighbourBaseDN), null); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(owned, true); |
| | | } |
| | | }); |
| | | neighbour.open(AccessMode.READ_WRITE); |
| | | neighbour.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(neighbourTree, true); |
| | | } |
| | | }); |
| | | // the state of a backend upgraded from a version keeping no catalog: its tables are there |
| | | // and nothing names them, so the clear that follows drops nothing at all |
| | | dropTableBehindTheBackend(storage.getTableName(storage.getCatalogTree())); |
| | | storage.close(); |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | assertTrue(isExistsTable(storage.getTableName(owned)), |
| | | "a table named by no catalog was dropped: nothing may be dropped that cannot be attributed"); |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { |
| | | // asked the way a clear asks it, through the same normalisation: a driver naming its |
| | | // catalog with an empty string names no catalog, and a metadata pattern reads that as |
| | | // "the tables belonging to no catalog at all", which would answer nothing |
| | | final JDBCStorage.ClearLeftovers leftovers = |
| | | storage.leftoverTables(con, JDBCStorage.TableScope.of(storage, con)); |
| | | assertNotNull(leftovers, "the database would not say which tables the clear left standing"); |
| | | assertTrue(leftovers.ours.toString().toLowerCase().contains(storage.getTableName(owned).toLowerCase()), |
| | | "a table of a base DN this backend serves was not reported as its own: " + leftovers.ours); |
| | | assertFalse(leftovers.unattributed.toString().toLowerCase().contains(storage.getTableName(owned).toLowerCase()), |
| | | "a table this backend can name was reported as attributable to nobody: " + leftovers.unattributed); |
| | | assertTrue(leftovers.unreadable.isEmpty(), |
| | | "the stamp of a table this database does give up was reported as unreadable: " + leftovers.unreadable); |
| | | } |
| | | assertReportsNothingOf(storage, neighbour, neighbourTree); |
| | | } finally { |
| | | clearQuietly(neighbour); |
| | | // the catalog of this one is gone, so its clear names nothing: the table it left standing on |
| | | // purpose is dropped here by hand, as the report says such a table has to be |
| | | clearQuietly(storage); |
| | | dropTableIfExists(storage.getTableName(owned)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Asserts that the clear of one backend says nothing whatsoever about the tables of another - and |
| | | * that the silence is one about tables the scan does reach: the backend those tables belong to is |
| | | * asked the same question and reports them as its own, so an absence here is a decision and not a |
| | | * scan that enumerated nothing. |
| | | */ |
| | | private void assertReportsNothingOf(JDBCStorage cleared, JDBCStorage other, TreeName otherTree) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl())) { |
| | | final JDBCStorage.ClearLeftovers leftovers = |
| | | cleared.leftoverTables(con, JDBCStorage.TableScope.of(cleared, con)); |
| | | assertNotNull(leftovers, "the database would not say which tables the clear left standing"); |
| | | final String reported = |
| | | (leftovers.ours + " " + leftovers.unattributed + " " + leftovers.unreadable).toLowerCase(); |
| | | assertFalse(reported.contains(other.getTableName(otherTree).toLowerCase()), |
| | | "the clear of one backend reported the table of another: " + reported); |
| | | assertFalse(reported.contains(other.getTableName(other.getCatalogTree()).toLowerCase()), |
| | | "the clear of one backend reported the catalog of another: " + reported); |
| | | |
| | | final JDBCStorage.ClearLeftovers theirs = |
| | | other.leftoverTables(con, JDBCStorage.TableScope.of(other, con)); |
| | | assertNotNull(theirs, "the database would not say which tables the neighbour is holding"); |
| | | assertTrue(theirs.ours.toString().toLowerCase().contains(other.getTableName(otherTree).toLowerCase()), |
| | | "the table left unreported is one the scan does not reach at all: " + theirs.ours); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A clear which dropped nothing at all says why, where the only thing it had to say is a row it |
| | | * could not act on: the catalog table went between the read of its rows and the loop that drops |
| | | * what they named - an offline tool clearing the same backend, and the one state in which a clear |
| | | * passes a row over and still drops nothing - so the account of it has no drop, no tree that had |
| | | * lost its table and no leftover of this backend to be decided by. The row is what is left, and |
| | | * the line reporting it is the only copy of what that row said. |
| | | * <p> |
| | | * What the case pins is one term of that condition. It pins it on a database holding nothing of |
| | | * anybody else that the scan cannot attribute - the fragments asserted are the counts this case |
| | | * owns, and a neighbour of another suite leaving an unstamped table would make the line fire for |
| | | * a reason of its own rather than fail this. |
| | | */ |
| | | @Test |
| | | public void testAClearWhichDroppedNothingSaysWhyWhereARowItPassedOverIsAllItHad() throws Exception { |
| | | final DN baseDN = DN.valueOf("dc=clear-catalog-race,dc=com"); |
| | | final TreeName owned = new TreeName(baseDN.toNormalizedUrlSafeString(), "id2entry"); |
| | | final ReportingStorage storage = |
| | | new ReportingStorage(createBackendCfg(getBackendId() + "_catalogRace", baseDN)); |
| | | final String catalogTable = storage.getTableName(storage.getCatalogTree()); |
| | | final String ownedTable = storage.getTableName(owned); |
| | | try { |
| | | storage.open(AccessMode.READ_WRITE); |
| | | storage.write(new WriteOperation() { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception { |
| | | txn.openTree(owned, true); |
| | | } |
| | | }); |
| | | // the one row of the catalog now records a name outside the namespace this backend names |
| | | // its tables in, so the read passes it over and the catalog names itself alone |
| | | recordAnotherTable(catalogTable, "a_table_of_something_else"); |
| | | // and nothing of this backend is left standing for the scan to attribute to it |
| | | dropTableBehindTheBackend(ownedTable); |
| | | storage.close(); |
| | | |
| | | // the table goes while the clear is running, which is what leaves the clear with nothing |
| | | // dropped: an offline tool clearing the same backend a moment earlier. At the second |
| | | // lookup and not the first, so that the rows are read before the table goes - the first |
| | | // is catalogTables() asking whether there is a catalog at all |
| | | storage.takeAwayAtLookupNumber(catalogTable, 2); |
| | | |
| | | storage.removeStorageFiles(); |
| | | |
| | | assertFalse(isExistsTable(catalogTable), "the catalog table this case takes away was still there"); |
| | | storage.assertReported("a clear which dropped nothing at all and passed a row over said nothing" |
| | | + " about why, which is the silence of #888", |
| | | "the clear removed no tree of this backend", "it dropped 0 table(s) in all", |
| | | "0 of the trees its catalog names had lost their table already", |
| | | "and 0 table(s) of this backend were named by no catalog"); |
| | | storage.assertReported("the row the clear could not act on was named by no line of it", |
| | | "a_table_of_something_else", "passed over"); |
| | | } finally { |
| | | clearQuietly(storage); |
| | | dropTableIfExists(ownedTable); |
| | | dropTableIfExists(catalogTable); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Takes every row out of a catalog, leaving the tables it named standing: what a backend upgraded |
| | | * from a version keeping no catalog holds before its first read-write open fills one in. |
| | | */ |
| | | private void emptyTheCatalog(String catalogTable) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final Statement st = con.createStatement()) { |
| | | st.executeUpdate("delete from " + catalogTable); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Records the given table for every row of a catalog, as a version naming its tables otherwise |
| | | * would have left them: the row names the right tree and a table this version never creates. |
| | | */ |
| | | private void recordAnotherTable(String catalogTable, String tableName) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final PreparedStatement statement = con.prepareStatement("update " + catalogTable + " set v=?")) { |
| | | statement.setBytes(1, tableName.getBytes(StandardCharsets.UTF_8)); |
| | | statement.executeUpdate(); |
| | | } |
| | | } |
| | | |
| | | /** Empties the recorded table name of every row of a catalog, as a version recording none would have left it. */ |
| | | private void emptyTheRecordedTableNames(String catalogTable) throws SQLException { |
| | | try (final Connection con = DriverManager.getConnection(getJdbcUrl()); |
| | | final PreparedStatement statement = con.prepareStatement("update " + catalogTable + " set v=?")) { |
| | | statement.setBytes(1, new byte[0]); |
| | | statement.executeUpdate(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Drops a table a clear left standing on purpose, so that it is not left behind for the rest of |
| | | * the class. A failure here is swallowed rather than replacing the failure of the case it cleans |
| | | * up after: what it leaves is dropped by the dropStaleTrees() of the next run of the class. |
| | | */ |
| | | private void dropTableIfExists(String tableName) { |
| | | try { |
| | | if (isExistsTable(tableName)) { |
| | | dropTableBehindTheBackend(tableName); |
| | | } |
| | | } catch (SQLException ignored) { |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A storage which keeps the lines every clear it runs reports, so that a case can hold that |
| | | * account to what it says. |
| | | * <p> |
| | | * Those lines change no state whatsoever, so a case asserting on the database a clear leaves |
| | | * behind passes just as well with all of them deleted - which is how this report came to be |
| | | * changed in three rounds of review with nothing able to fail. Here rather than in the case that |
| | | * needed it first, for the same reason: the next line of the report wants an assertion too, and a |
| | | * helper per case is what got the report where it was. See {@link JDBCStorage#reportClearLine}. |
| | | */ |
| | | protected static final class ReportingStorage extends JDBCStorage { |
| | | private final List<String> lines = Collections.synchronizedList(new ArrayList<String>()); |
| | | |
| | | private volatile String tableToTakeAway; |
| | | private final AtomicInteger lookupsToLetPass = new AtomicInteger(); |
| | | |
| | | ReportingStorage(JDBCBackendCfg cfg) { |
| | | super(cfg, null); |
| | | } |
| | | |
| | | @Override |
| | | void reportClearLine(LocalizableMessage line) { |
| | | lines.add(line.toString()); |
| | | super.reportClearLine(line); // and on to the log, which is where an operator meets it |
| | | } |
| | | |
| | | /** |
| | | * Takes the named table away just before the given lookup of it, counting from the next one, |
| | | * so that the lookup answers as another process taking the table a moment earlier would have |
| | | * made it answer. What it models is the one state a clear cannot be put into from outside: a |
| | | * table going between the read of the catalog and the loop that drops what that read named. |
| | | * <p> |
| | | * Which lookup matters, and the count is not decoration: a clear asks about its catalog table |
| | | * twice - once in {@code catalogTables()} to decide whether there is a catalog to read at all, |
| | | * and once in the drop loop, per entry. Taken away before the first, the clear reads no row, |
| | | * passes none over and reports nothing, which is a different case from this one. |
| | | * <p> |
| | | * Dropped on the very connection the lookup is made on, and not on one of the test's own: the |
| | | * clear holds its read of the catalog table until it commits, so a {@code drop table} issued |
| | | * from a second session would queue behind the transaction that is waiting for this call to |
| | | * return. Inside that transaction the drop takes no lock it does not already hold, and it is |
| | | * committed with the loop - or, on the two engines committing DDL as they go, at once. |
| | | */ |
| | | void takeAwayAtLookupNumber(String tableName, int nth) { |
| | | lookupsToLetPass.set(nth - 1); |
| | | tableToTakeAway = tableName; |
| | | } |
| | | |
| | | @Override |
| | | boolean isExistsTable(Connection con, JDBCStorage.TableScope scope, String tableName) { |
| | | final String taking = tableToTakeAway; |
| | | if (taking != null && taking.equalsIgnoreCase(tableName) |
| | | && lookupsToLetPass.getAndDecrement() <= 0) { |
| | | tableToTakeAway = null; // once: every later lookup is answered by the database alone |
| | | try (final PreparedStatement statement = con.prepareStatement("drop table " + taking)) { |
| | | statement.execute(); |
| | | } catch (SQLException e) { |
| | | throw new IllegalStateException("the table this case takes away could not be dropped", e); |
| | | } |
| | | } |
| | | return super.isExistsTable(con, scope, tableName); |
| | | } |
| | | |
| | | /** Every line reported so far, in the order the clears that reported them ran. */ |
| | | List<String> reported() { |
| | | synchronized (lines) { |
| | | return new ArrayList<>(lines); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Fails unless one reported line holds every one of the fragments. By fragments and not by the |
| | | * whole line: what a case is entitled to pin is the thing the line is about, and a report |
| | | * asserted word for word is a report nobody may improve the wording of. |
| | | */ |
| | | void assertReported(String whatWentUnsaid, String... fragments) { |
| | | for (final String line : reported()) { |
| | | boolean holdsAll = true; |
| | | for (final String fragment : fragments) { |
| | | holdsAll &= line.contains(fragment); |
| | | } |
| | | if (holdsAll) { |
| | | return; |
| | | } |
| | | } |
| | | fail(whatWentUnsaid + "; the clear reported: " + reported()); |
| | | } |
| | | } |
| | | } |
| | |
| | | * information: "Portions Copyright [year] [name of copyright owner]". |
| | | * |
| | | * Copyright 2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.pdb; |
| | | |
| | |
| | | import static org.opends.server.util.StaticUtils.*; |
| | | import static org.forgerock.opendj.ldap.ByteString.*; |
| | | |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | |
| | | import org.forgerock.opendj.config.server.ConfigException; |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.opends.server.DirectoryServerTestCase; |
| | | import org.opends.server.TestCaseUtils; |
| | | import org.forgerock.opendj.server.config.server.PDBBackendCfg; |
| | | import org.opends.server.backends.pluggable.spi.AccessMode; |
| | | import org.opends.server.backends.pluggable.spi.ReadOperation; |
| | | import org.opends.server.backends.pluggable.spi.ReadableTransaction; |
| | | import org.opends.server.backends.pluggable.spi.StorageRuntimeException; |
| | | import org.opends.server.backends.pluggable.spi.TreeName; |
| | | import org.opends.server.backends.pluggable.spi.WriteOperation; |
| | | import org.opends.server.backends.pluggable.spi.WriteableTransaction; |
| | |
| | | import org.testng.annotations.Test; |
| | | |
| | | import com.persistit.Exchange; |
| | | import com.persistit.exception.RollbackException; |
| | | |
| | | public class PDBStorageTest extends DirectoryServerTestCase |
| | | { |
| | | /** A window no run of replays can spend, so that a test of the attempt cap is only ever ended by the cap. */ |
| | | private static final long UNREACHABLE_RETRY_WINDOW_NANOS = 300L * 1000L * 1000L * 1000L; //5 min |
| | | /** A window a single attempt outlasts, so that a test of the window reaches it without seconds of build time. */ |
| | | private static final long SHORT_RETRY_WINDOW_NANOS = 200L * 1000L * 1000L; //200 ms |
| | | /** An attempt long enough to outlast {@link #SHORT_RETRY_WINDOW_NANOS} on its own, in milliseconds. */ |
| | | private static final long ATTEMPT_LONGER_THAN_SHORT_WINDOW_MS = 300; |
| | | |
| | | private final TreeName treeName = new TreeName("dc=test", "test"); |
| | | private ServerContext serverContext; |
| | | private PDBStorage storage; |
| | | |
| | | @BeforeClass |
| | |
| | | @BeforeMethod |
| | | public void setUp() throws ConfigException |
| | | { |
| | | ServerContext serverContext = mock(ServerContext.class); |
| | | serverContext = mock(ServerContext.class); |
| | | when(serverContext.getMemoryQuota()).thenReturn(new MemoryQuota()); |
| | | when(serverContext.getDiskSpaceMonitor()).thenReturn(mock(DiskSpaceMonitor.class)); |
| | | |
| | | storage = new PDBStorage(createBackendCfg(), serverContext); |
| | | // the volume is removed on the way in as well as on the way out: a build whose JVM died never ran tearDown(), |
| | | // and this class shares a fixed db-directory across methods and across builds, so what that run left behind |
| | | // would still be here to answer this method's reads |
| | | storage.removeStorageFiles(); |
| | | storage.open(AccessMode.READ_WRITE); |
| | | } |
| | | |
| | | @AfterMethod |
| | | public void tearDown() |
| | | { |
| | | storage.close(); |
| | | closeAndRemove(storage); |
| | | } |
| | | |
| | | /** |
| | | * Closes the storage and removes its volume, keeping whichever of the two failed first. Removing it from a |
| | | * finally would let a removal failure replace the close() failure (JLS 14.20.2) - and a close() that throws is |
| | | * exactly the case the removal is here for. |
| | | */ |
| | | private static void closeAndRemove(PDBStorage storage) |
| | | { |
| | | RuntimeException failure = null; |
| | | try |
| | | { |
| | | storage.close(); |
| | | } |
| | | catch (RuntimeException e) |
| | | { |
| | | failure = e; |
| | | } |
| | | try |
| | | { |
| | | storage.removeStorageFiles(); |
| | | } |
| | | catch (RuntimeException e) |
| | | { |
| | | if (failure == null) |
| | | { |
| | | failure = e; |
| | | } |
| | | else |
| | | { |
| | | failure.addSuppressed(e); |
| | | } |
| | | } |
| | | if (failure != null) |
| | | { |
| | | throw failure; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Replaces the storage under test with one bounded by the given values, so that the bound a test is about is |
| | | * the one that ends its replays. With the shipped values the two race: the nine backoffs of a full ladder draw |
| | | * from 50+100+200+400+800+1000x4, so an attempt cap test can be ended by the ten second window instead, and a |
| | | * window test has to make every attempt outlast seconds of that window to reach it. |
| | | */ |
| | | private void reopenWithReplayBounds(int maxRetries, long retryWindowNanos) throws Exception |
| | | { |
| | | closeAndRemove(storage); |
| | | storage = new PDBStorage(createBackendCfg(), serverContext, maxRetries, retryWindowNanos); |
| | | storage.open(AccessMode.READ_WRITE); |
| | | } |
| | | |
| | | @Test |
| | |
| | | assertThat(storage.getNewExchange(treeName, true)).isNotSameAs(initial); |
| | | } |
| | | |
| | | @Test |
| | | public void testWriteGivesUpAfterTheAttemptCap() throws Exception |
| | | { |
| | | // the shipped cap, against a window the ladder of backoffs cannot reach: on the shipped window those nine |
| | | // backoffs draw from up to 5550 ms, so a loaded machine ends this loop on the window and the cap goes untested |
| | | reopenWithReplayBounds(PDBStorage.MAX_RETRIES, UNREACHABLE_RETRY_WINDOW_NANOS); |
| | | createTree(); |
| | | |
| | | final RollbackException conflict = new RollbackException(); |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | try |
| | | { |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | attempts.incrementAndGet(); |
| | | txn.put(treeName, valueOfUtf8("abandoned"), valueOfUtf8("value")); |
| | | throw conflict; |
| | | } |
| | | }); |
| | | failBecauseExceptionWasNotThrown(StorageRuntimeException.class); |
| | | } |
| | | catch (StorageRuntimeException e) |
| | | { |
| | | assertThat(e.getSuppressed()).contains(conflict); |
| | | } |
| | | assertThat(attempts.get()).isEqualTo(PDBStorage.MAX_RETRIES); |
| | | assertThat(read("abandoned")).isNull(); |
| | | } |
| | | |
| | | @Test |
| | | public void testWriteIsReplayedUntilTheConflictClears() throws Exception |
| | | { |
| | | createTree(); |
| | | |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | if (attempts.incrementAndGet() <= 3) |
| | | { |
| | | throw new RollbackException(); |
| | | } |
| | | txn.put(treeName, valueOfUtf8("applied"), valueOfUtf8("value")); |
| | | } |
| | | }); |
| | | |
| | | assertThat(attempts.get()).isEqualTo(4); |
| | | assertThat(read("applied")).isEqualTo(valueOfUtf8("value")); |
| | | } |
| | | |
| | | /** |
| | | * PersistIt reports a write-write conflict only once it has waited on it - up to |
| | | * {@code SharedResource.DEFAULT_MAX_WAIT_TIME}, a minute, which this backend never lowers - so a single attempt |
| | | * can outlast the whole window. Giving up on the window alone would then replay nothing, in the very case where |
| | | * the replay is likeliest to succeed: the transaction that was blocking this one has just finished. |
| | | */ |
| | | @Test |
| | | public void testWriteIsReplayedOnceWhenTheFirstAttemptOutlastsTheWindow() throws Exception |
| | | { |
| | | reopenWithReplayBounds(PDBStorage.MAX_RETRIES, SHORT_RETRY_WINDOW_NANOS); |
| | | createTree(); |
| | | |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | if (attempts.incrementAndGet() == 1) |
| | | { |
| | | Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_WINDOW_MS); |
| | | throw new RollbackException(); |
| | | } |
| | | txn.put(treeName, valueOfUtf8("outlasted"), valueOfUtf8("written")); |
| | | } |
| | | }); |
| | | |
| | | assertThat(attempts.get()).isEqualTo(2); |
| | | assertThat(read("outlasted")).isEqualTo(valueOfUtf8("written")); |
| | | } |
| | | |
| | | @Test |
| | | public void testExhaustedWriteNamesTheAttemptsItSpent() throws Exception |
| | | { |
| | | // the message is the same at any cap, so this one is spent in two backoffs rather than in the shipped ladder |
| | | final int maxRetries = 3; |
| | | reopenWithReplayBounds(maxRetries, UNREACHABLE_RETRY_WINDOW_NANOS); |
| | | createTree(); |
| | | |
| | | try |
| | | { |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | throw new RollbackException(); |
| | | } |
| | | }); |
| | | failBecauseExceptionWasNotThrown(StorageRuntimeException.class); |
| | | } |
| | | catch (StorageRuntimeException e) |
| | | { |
| | | assertThat(e.getMessage()).contains("PDBStorageTest").contains(maxRetries + " attempts"); |
| | | // and which of the two bounds ran out, since the attempt count alone does not say |
| | | assertThat(e.getMessage()).contains("attempt cap"); |
| | | // write() unwraps a StorageRuntimeException that carries a cause, which would replace this message with |
| | | // the bare RollbackException, and it is the message the config change paths report |
| | | assertThat(e.getCause()).isNull(); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | public void testWriteGivesUpOnTheWindowWhenAttemptsAreSlow() throws Exception |
| | | { |
| | | reopenWithReplayBounds(PDBStorage.MAX_RETRIES, SHORT_RETRY_WINDOW_NANOS); |
| | | createTree(); |
| | | |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | try |
| | | { |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | attempts.incrementAndGet(); |
| | | // a conflict this slow to report spends the wall clock window long before the attempt cap |
| | | Thread.sleep(ATTEMPT_LONGER_THAN_SHORT_WINDOW_MS); |
| | | throw new RollbackException(); |
| | | } |
| | | }); |
| | | failBecauseExceptionWasNotThrown(StorageRuntimeException.class); |
| | | } |
| | | catch (StorageRuntimeException e) |
| | | { |
| | | // the window is what ended it, and it says so: an assertion on the attempt count alone would also pass for |
| | | // a give up on attempt 1, which is the regression the attempt > 1 exemption exists to prevent |
| | | assertThat(e.getMessage()).contains("retry window"); |
| | | } |
| | | // one attempt beyond the first: the first spends the window, the exemption grants the replay, and the check |
| | | // after that replay is the one that gives up |
| | | assertThat(attempts.get()).isEqualTo(2); |
| | | } |
| | | |
| | | @Test |
| | | public void testInterruptedWriteReportsTheConflictItWasReplaying() throws Exception |
| | | { |
| | | createTree(); |
| | | |
| | | final RollbackException conflict = new RollbackException(); |
| | | final AtomicInteger attempts = new AtomicInteger(); |
| | | final boolean interruptedAfterwards; |
| | | try |
| | | { |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | attempts.incrementAndGet(); |
| | | // interrupted here rather than before the write, where the transaction this attempt begins would |
| | | // report the interrupt itself and the loop would never reach the backoff being tested |
| | | Thread.currentThread().interrupt(); |
| | | throw conflict; |
| | | } |
| | | }); |
| | | failBecauseExceptionWasNotThrown(StorageRuntimeException.class); |
| | | return; |
| | | } |
| | | catch (StorageRuntimeException e) |
| | | { |
| | | interruptedAfterwards = Thread.interrupted(); |
| | | // the conflict, not the interrupt, is what the caller is told about - but through the same shape the |
| | | // exhausted loop uses, since a bare RollbackException reaches every caller as its own class name |
| | | assertThat(e.getMessage()).contains("PDBStorageTest").contains("interrupted"); |
| | | assertThat(e.getSuppressed()).contains(conflict).hasAtLeastOneElementOfType(InterruptedException.class); |
| | | assertThat(e.getCause()).isNull(); |
| | | } |
| | | finally |
| | | { |
| | | Thread.interrupted(); |
| | | } |
| | | // sleep() cleared the flag, so the caller only learns of the interrupt if the loop restores it |
| | | assertThat(interruptedAfterwards).isTrue(); |
| | | // one attempt even though the first backoff is a random 0-49 ms and so is sometimes 0: Thread.sleep() checks |
| | | // the interrupt flag before it checks for a zero duration, so the replay is never reached |
| | | assertThat(attempts.get()).isEqualTo(1); |
| | | } |
| | | |
| | | /** |
| | | * The delay grows with the attempt and stays under the cap, so that a contention the first delays did not |
| | | * outlast still has a chance to clear without the replays overrunning the window on sleep alone. |
| | | */ |
| | | @Test |
| | | public void testRetryDelayGrowsAndStaysBounded() |
| | | { |
| | | long previousBound = 0; |
| | | for (int attempt = 1; attempt <= PDBStorage.MAX_RETRIES; attempt++) |
| | | { |
| | | long bound = 0; |
| | | for (int i = 0; i < 100; i++) |
| | | { |
| | | final long delay = PDBStorage.retryDelayMillis(attempt); |
| | | assertThat(delay).as("attempt %d", attempt).isGreaterThanOrEqualTo(0).isLessThan(1000); |
| | | bound = Math.max(bound, delay); |
| | | } |
| | | if (attempt == 1) |
| | | { |
| | | // the flat sleep this loop took before it was bounded, unchanged: only the later attempts back off |
| | | assertThat(bound).as("attempt 1 delays past the sleep this loop always took").isLessThan(50); |
| | | } |
| | | assertThat(bound).as("attempt %d did not grow past attempt %d", attempt, attempt - 1) |
| | | .isGreaterThanOrEqualTo(previousBound / 2); |
| | | previousBound = bound; |
| | | } |
| | | // and the growth is real rather than a delay that never leaves the first tier |
| | | long grown = 0; |
| | | for (int i = 0; i < 100; i++) |
| | | { |
| | | grown = Math.max(grown, PDBStorage.retryDelayMillis(PDBStorage.MAX_RETRIES)); |
| | | } |
| | | assertThat(grown).as("the last attempts still sleep within the first attempt's bound").isGreaterThan(500); |
| | | } |
| | | |
| | | private void createTree() throws Exception |
| | | { |
| | | storage.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | txn.openTree(treeName, true); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | private ByteString read(final String key) throws Exception |
| | | { |
| | | return storage.read(new ReadOperation<ByteString>() |
| | | { |
| | | @Override |
| | | public ByteString run(ReadableTransaction txn) throws Exception |
| | | { |
| | | return txn.read(treeName, valueOfUtf8(key)); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | protected PDBBackendCfg createBackendCfg() |
| | | { |
| | | PDBBackendCfg backendCfg = mockCfg(PDBBackendCfg.class); |
| New file |
| | |
| | | /* |
| | | * 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 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.backends.pluggable; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | 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.forgerock.opendj.config.ConfigurationMock.mockCfg; |
| | | import static org.mockito.Mockito.any; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.times; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | import static org.opends.server.backends.pluggable.State.IndexFlag.TRUSTED; |
| | | import static org.opends.server.backends.pluggable.SuffixContainer.STATE_INDEX_NAME; |
| | | import static org.opends.server.util.CollectionUtils.newTreeSet; |
| | | import static org.forgerock.util.Utils.closeSilently; |
| | | |
| | | import java.util.EnumSet; |
| | | import java.util.HashSet; |
| | | import java.util.Set; |
| | | import java.util.SortedSet; |
| | | import java.util.TreeSet; |
| | | import java.util.concurrent.locks.ReentrantReadWriteLock; |
| | | |
| | | import org.forgerock.i18n.LocalizableMessage; |
| | | import org.forgerock.opendj.config.server.ConfigChangeResult; |
| | | import org.forgerock.opendj.config.server.ConfigException; |
| | | import org.forgerock.opendj.ldap.ByteSequence; |
| | | import org.forgerock.opendj.ldap.ByteString; |
| | | import org.forgerock.opendj.ldap.DN; |
| | | import org.forgerock.opendj.ldap.ResultCode; |
| | | import org.forgerock.opendj.ldap.schema.AttributeType; |
| | | import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType; |
| | | import org.forgerock.opendj.server.config.server.BackendIndexCfg; |
| | | 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.State.IndexFlag; |
| | | import org.opends.server.backends.pluggable.spi.AccessMode; |
| | | import org.opends.server.backends.pluggable.spi.Cursor; |
| | | import org.opends.server.backends.pluggable.spi.Importer; |
| | | import org.opends.server.backends.pluggable.spi.ReadOperation; |
| | | import org.opends.server.backends.pluggable.spi.Storage; |
| | | import org.opends.server.backends.pluggable.spi.StorageRuntimeException; |
| | | import org.opends.server.backends.pluggable.spi.StorageStatus; |
| | | import org.opends.server.backends.pluggable.spi.TreeName; |
| | | 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.ServerContext; |
| | | import org.opends.server.types.BackupConfig; |
| | | import org.opends.server.types.BackupDirectory; |
| | | import org.opends.server.types.DirectoryException; |
| | | import org.opends.server.types.RestoreConfig; |
| | | import org.testng.annotations.AfterMethod; |
| | | import org.testng.annotations.BeforeClass; |
| | | import org.testng.annotations.Test; |
| | | |
| | | import com.persistit.exception.RollbackException; |
| | | |
| | | /** |
| | | * Tests that {@link BackendImpl#applyConfigurationChange} survives a replay of its |
| | | * {@link WriteOperation}. {@link Storage#write(WriteOperation)} may replay the operation after a |
| | | * transaction conflict, so every side effect it performs must either be transactional or be |
| | | * idempotent - see OpenDJ issue #907. |
| | | * <p> |
| | | * The conflict is raised from inside the operation as the {@link RollbackException} PersistIt |
| | | * itself raises, so that the replay is driven by {@code PDBStorage.write}'s own retry loop rather |
| | | * than by a second call to it. That loop keeps one storage implementation - and with it its cache |
| | | * of PersistIt exchanges - across every attempt, which a second call would not. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | @Test(groups = { "precommit", "pluggablebackend" }, sequential = true) |
| | | public class ReplayedConfigChangeTest extends DirectoryServerTestCase |
| | | { |
| | | private static final String BACKEND_ID = "ReplayedConfigChangeTest"; |
| | | private static final DN KEPT = DN.valueOf("dc=b907a,dc=com"); |
| | | private static final DN REMOVED = DN.valueOf("dc=b907b,dc=com"); |
| | | 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"); |
| | | |
| | | private ServerContext serverContext; |
| | | private AttributeType cnType; |
| | | |
| | | @BeforeClass |
| | | public void startServer() throws Exception |
| | | { |
| | | TestCaseUtils.startServer(); |
| | | serverContext = TestCaseUtils.getServerContext(); |
| | | cnType = serverContext.getSchema().getAttributeType("cn"); |
| | | } |
| | | |
| | | /** |
| | | * These tests are designed to fail, and a failing one can leave a base DN behind in the server |
| | | * wide registry, where it would outlive the test and break the next one to use that DN. |
| | | */ |
| | | @AfterMethod |
| | | public void deregisterLeftoverBaseDNs() |
| | | { |
| | | for (DN baseDN : new DN[] { KEPT, REMOVED, ADDED, UNREGISTRABLE }) |
| | | { |
| | | try |
| | | { |
| | | serverContext.getBackendConfigManager().deregisterBaseDN(baseDN); |
| | | } |
| | | catch (Exception alreadyGone) |
| | | { |
| | | // Which is what the test should have left behind. |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A base DN removal whose transaction conflicts before it touches the storage must be replayed |
| | | * without reporting a failure against the base DN it has already deregistered. |
| | | */ |
| | | @Test |
| | | public void removalIsReplayableWhenTheTransactionConflictsBeforeAnyStorageAccess() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | assertThat(rootContainer.getBaseDNs()).contains(REMOVED); |
| | | |
| | | backend.storage.conflictAtFirstStorageAccess(1); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT))); |
| | | |
| | | assertThat(backend.storage.attempts()).isEqualTo(2); |
| | | assertThat(ccr.getMessages()).isEmpty(); |
| | | assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); |
| | | assertThat(rootContainer.getBaseDNs()).doesNotContain(REMOVED); |
| | | assertThat(backend.getBaseDNs()).doesNotContain(REMOVED); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(REMOVED)).isNull(); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A base DN addition whose transaction conflicts at commit time must be replayed, so that what |
| | | * the entry container it opens writes ends up committed rather than discarded by the rollback. |
| | | */ |
| | | @Test |
| | | public void additionIsReplayableWhenTheTransactionConflictsAtCommitTime() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | assertThat(rootContainer.getBaseDNs()).doesNotContain(ADDED); |
| | | |
| | | backend.storage.conflictAtCommit(1); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED))); |
| | | |
| | | assertThat(backend.storage.attempts()).isEqualTo(2); |
| | | assertThat(ccr.getMessages()).isEmpty(); |
| | | assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); |
| | | assertThat(rootContainer.getBaseDNs()).contains(ADDED); |
| | | assertThat(backend.getBaseDNs()).contains(ADDED); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(ADDED)).isSameAs(backend); |
| | | // Everything the newly opened entry container wrote belongs to the rolled back transaction, |
| | | // so the storage has to be asked, not the entry container which remembers writing it. |
| | | final EntryContainer ec = rootContainer.getEntryContainer(ADDED); |
| | | final TreeName cnIndex = ec.getAttributeIndex(cnType).getNameToIndexes().values().iterator().next().getName(); |
| | | assertThat(rootContainer.getStorage().listTrees()).contains(cnIndex); |
| | | assertThat(persistedFlags(rootContainer, ec, cnIndex)).contains(TRUSTED); |
| | | // The entry container the rolled back attempt opened registered five configuration listeners, |
| | | // which only its close() takes back, so the replay has to give it up before opening another. |
| | | verify(backend.configuredWith, times(1)).removePluggableChangeListener(any()); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The trees of a removed base DN are deleted by the operation itself, so a replay deletes trees a |
| | | * rolled back attempt had already deleted. This is the case which reaches the storage, and it |
| | | * removes and adds a base DN at once because that is what an operator editing the configuration |
| | | * does. |
| | | */ |
| | | @Test |
| | | public void aRemovalAndAnAdditionInOneChangeSurviveRepeatedReplay() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | final Set<TreeName> removedTrees = treesOf(rootContainer.getEntryContainer(REMOVED)); |
| | | assertThat(rootContainer.getStorage().listTrees()).containsAll(removedTrees); |
| | | |
| | | // More than one conflict, because the contract is that the operation is replayed until it |
| | | // succeeds rather than that it survives a single replay. |
| | | backend.storage.conflictAtCommit(2); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED))); |
| | | |
| | | assertThat(backend.storage.attempts()).isEqualTo(3); |
| | | assertThat(ccr.getMessages()).isEmpty(); |
| | | assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); |
| | | assertThat(rootContainer.getBaseDNs()).contains(KEPT, ADDED).doesNotContain(REMOVED); |
| | | assertThat(backend.getBaseDNs()).contains(KEPT, ADDED).doesNotContain(REMOVED); |
| | | |
| | | final Set<TreeName> storedTrees = rootContainer.getStorage().listTrees(); |
| | | assertThat(storedTrees).doesNotContainAnyElementsOf(removedTrees); |
| | | assertThat(storedTrees).containsAll(treesOf(rootContainer.getEntryContainer(ADDED))); |
| | | assertThat(storedTrees).containsAll(treesOf(rootContainer.getEntryContainer(KEPT))); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A failure the storage engine does not replay must leave the backend as it was and say which |
| | | * base DNs the change was about, since the failure itself never names them. |
| | | */ |
| | | @Test |
| | | public void aFailureWhichIsNotReplayedAppliesNothingAndNamesTheBaseDNs() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | final Set<TreeName> removedTrees = treesOf(rootContainer.getEntryContainer(REMOVED)); |
| | | |
| | | backend.storage.failWithoutReplay(); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED))); |
| | | |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ccr.getMessages().toString()).contains(REMOVED.toString()).contains(ADDED.toString()); |
| | | |
| | | // Nothing was registered, nothing was deregistered, and the rollback put the trees back. |
| | | assertThat(rootContainer.getBaseDNs()).contains(REMOVED).doesNotContain(ADDED); |
| | | assertThat(backend.getBaseDNs()).contains(REMOVED).doesNotContain(ADDED); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(REMOVED)).isSameAs(backend); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(ADDED)).isNull(); |
| | | assertThat(rootContainer.getStorage().listTrees()).containsAll(removedTrees); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A failure which the storage engine neither replays nor rolls back - the DDL of mysql and oracle |
| | | * commits of its own accord, and cassandra has no transaction at all - leaves the trees of a |
| | | * removed base DN gone. That base DN has to stop being reachable, or every operation against it |
| | | * meets a storage error rather than the "no such entry" its removal was meant to leave. |
| | | */ |
| | | @Test |
| | | public void aFailureWhichIsNotRolledBackGivesUpTheBaseDNsWhoseTreesAreGone() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | final Set<TreeName> removedTrees = treesOf(rootContainer.getEntryContainer(REMOVED)); |
| | | |
| | | backend.storage.failAfterCommit(); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED))); |
| | | |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ccr.adminActionRequired()).isTrue(); |
| | | assertThat(ccr.getMessages().toString()).contains(REMOVED.toString()).contains(ADDED.toString()); |
| | | |
| | | // The trees are gone, so the base DN is given up rather than left routed at them. |
| | | assertThat(rootContainer.getStorage().listTrees()).doesNotContainAnyElementsOf(removedTrees); |
| | | assertThat(rootContainer.getBaseDNs()).doesNotContain(REMOVED); |
| | | assertThat(backend.getBaseDNs()).doesNotContain(REMOVED); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(REMOVED)).isNull(); |
| | | |
| | | // The added base DN is not registered, since the change it belongs to failed. |
| | | assertThat(rootContainer.getBaseDNs()).doesNotContain(ADDED); |
| | | assertThat(backend.getBaseDNs()).doesNotContain(ADDED); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(ADDED)).isNull(); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The same failure leaves the trees it created for a base DN which is not being added after all |
| | | * exactly where they are. The configuration which names that base DN was stored before this |
| | | * listener was called - {@code ConfigurationHandler.replaceEntry} writes the entry, and only then |
| | | * notifies - and the failure does not take it back, so the next open of this backend opens that |
| | | * base DN again from it, adopting the trees which survived and creating the ones which did not. |
| | | * Deleting them here would take away the trees of a base DN the stored configuration still asks |
| | | * this backend to serve, and would buy nothing: that open re-creates them empty. |
| | | */ |
| | | @Test |
| | | public void aFailureWhichIsNotRolledBackLeavesTheTreesItCreated() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | final Set<TreeName> storedBefore = new HashSet<>(rootContainer.getStorage().listTrees()); |
| | | |
| | | backend.storage.failAfterCommit(); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, ADDED))); |
| | | |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(rootContainer.getBaseDNs()).doesNotContain(ADDED); |
| | | // Named by the failure, since nothing in the running server names them any more. |
| | | assertThat(ccr.getMessages().toString()).contains(ADDED.toString()); |
| | | |
| | | final Set<TreeName> left = new HashSet<>(rootContainer.getStorage().listTrees()); |
| | | left.removeAll(storedBefore); |
| | | assertThat(left).as("the trees created for the base DN the stored configuration still names") |
| | | .isNotEmpty(); |
| | | for (TreeName tree : left) |
| | | { |
| | | assertThat(tree.getBaseDN()).isEqualTo(ADDED.toNormalizedUrlSafeString()); |
| | | } |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Whether anything survived a failure is read from the trees the storage still holds, so a backend |
| | | * which cannot be asked for them reconciles nothing at all. The operator has to be told that, |
| | | * since it is the case where the failure alone says least about what the backend is left serving. |
| | | */ |
| | | @Test |
| | | public void aFailureWhoseSurvivingTreesCannotBeListedSaysSo() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | backend.storage.onListTrees(new Runnable() |
| | | { |
| | | @Override |
| | | public void run() |
| | | { |
| | | throw new StorageRuntimeException("the trees cannot be listed"); |
| | | } |
| | | }); |
| | | |
| | | backend.storage.failAfterCommit(); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT))); |
| | | backend.storage.onListTrees(null); |
| | | |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ccr.adminActionRequired()).isTrue(); |
| | | assertThat(ordinalsOf(ccr)).contains(ERR_BACKEND_CANNOT_LIST_TREES_AFTER_BASEDN_CHANGE.ordinal()); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A base DN this backend no longer holds must fail the change rather than the method: an entry |
| | | * container unregistered while the change was working out what to do leaves the root container |
| | | * with nothing to answer for that base DN, and the administration framework is owed a result |
| | | * whatever happens. |
| | | */ |
| | | @Test |
| | | public void aBaseDNTheBackendNoLongerHoldsFailsTheChangeRatherThanTheMethod() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | final PDBBackendCfg newCfg = backendCfg(newTreeSet(KEPT)); |
| | | when(newCfg.getBaseDN()).thenReturn(new UnregisteringWhenAsked(rootContainer, REMOVED, newTreeSet(KEPT))); |
| | | |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(newCfg); |
| | | |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ordinalsOf(ccr)).contains(ERR_BACKEND_BASEDN_NO_LONGER_HELD.ordinal()); |
| | | assertThat(ccr.getMessages().toString()).contains(REMOVED.toString()); |
| | | // Nothing was applied, so the base DNs this backend serves are the ones it served before. |
| | | assertThat(backend.getBaseDNs()).contains(KEPT); |
| | | assertThat(rootContainer.getStorage().listTrees()).containsAll(treesOf(rootContainer.getEntryContainer(KEPT))); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A base DN whose entry container is gone must not be answered with an ancestor's. |
| | | * {@link RootContainer#getEntryContainer} walks up the DN until it finds a container, which is how |
| | | * an entry is routed to the base DN above it; asked for a base DN the root container no longer |
| | | * holds, it hands back the container of the one it does. Deleting the trees of that container is |
| | | * deleting the trees of a base DN this backend is still serving. |
| | | * <p> |
| | | * Two base DNs of one backend are hierarchically related only after a registration the registry |
| | | * refused, which leaves the entry container behind in the root container - see |
| | | * {@link #aBaseDNWhichCannotBeRegisteredReportsWhereItFailed}. |
| | | */ |
| | | @Test |
| | | public void anEntryContainerWhichIsGoneIsNotAnsweredWithItsParent() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | // Refused by the registry, and so left in the root container underneath KEPT. |
| | | backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, UNREGISTRABLE))); |
| | | assertThat(rootContainer.getBaseDNs()).contains(KEPT, UNREGISTRABLE); |
| | | final Set<TreeName> keptTrees = treesOf(rootContainer.getEntryContainer(KEPT)); |
| | | |
| | | final PDBBackendCfg newCfg = backendCfg(newTreeSet(KEPT)); |
| | | when(newCfg.getBaseDN()).thenReturn(new UnregisteringWhenAsked(rootContainer, UNREGISTRABLE, newTreeSet(KEPT))); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(newCfg); |
| | | |
| | | // The base DN above the one which was gone is left alone, trees and routing both. |
| | | assertThat(rootContainer.getStorage().listTrees()) |
| | | .as("the trees of the base DN above the one which was gone").containsAll(keptTrees); |
| | | assertThat(rootContainer.getBaseDNs()).contains(KEPT); |
| | | assertThat(serverContext.getBackendConfigManager().getLocalBackendWithBaseDN(KEPT)).isSameAs(backend); |
| | | // And the change says which base DN stopped it. |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ordinalsOf(ccr)).contains(ERR_BACKEND_BASEDN_NO_LONGER_HELD.ordinal()); |
| | | assertThat(ccr.getMessages().toString()).contains(UNREGISTRABLE.toString()); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A base DN the registry refuses is reported with the whole of what refused it. The registry |
| | | * raises the same message for several reasons and from more than one place, so the exception's own |
| | | * text does not say which of them happened; the frames it was raised on do. |
| | | */ |
| | | @Test |
| | | public void aBaseDNWhichCannotBeRegisteredReportsWhereItFailed() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT)); |
| | | try |
| | | { |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, UNREGISTRABLE))); |
| | | |
| | | assertThat(ccr.getResultCode()).isNotEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ordinalsOf(ccr)).contains(ERR_BACKEND_CANNOT_REGISTER_BASEDN.ordinal()); |
| | | assertThat(ccr.getMessages().toString()) |
| | | .as("the reported cause never says where it was raised") |
| | | .contains("BackendConfigManager.java:"); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The entry container locks are held for the write which deletes the trees, and no longer: |
| | | * everything below the write reaches {@code BackendConfigManager}, whose single registry lock the |
| | | * server already takes in the opposite order - {@code shutdownLocalBackends} and a backend being |
| | | * disabled both hold it while closing a root container, which locks every entry container in turn. |
| | | * Holding both in this order would deadlock a base DN change against a shutdown, with no timeout |
| | | * on either side. |
| | | */ |
| | | @Test |
| | | public void theRegistryIsNotTouchedWhileAnEntryContainerLockIsHeld() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final RootContainer rootContainer = backend.getRootContainer(); |
| | | final EntryContainer removed = rootContainer.getEntryContainer(REMOVED); |
| | | // Listing the surviving trees is the last thing the failure path does before it deregisters, |
| | | // so it is asked on the very thread, and at the very moment, the deadlock would be reached. |
| | | final boolean[] lockHeld = new boolean[] { false }; |
| | | final boolean[] asked = new boolean[] { false }; |
| | | backend.storage.onListTrees(new Runnable() |
| | | { |
| | | @Override |
| | | public void run() |
| | | { |
| | | asked[0] = true; |
| | | lockHeld[0] |= ((ReentrantReadWriteLock.WriteLock) removed.exclusiveLock).isHeldByCurrentThread(); |
| | | } |
| | | }); |
| | | |
| | | backend.storage.failAfterCommit(); |
| | | backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT))); |
| | | backend.storage.onListTrees(null); |
| | | |
| | | assertThat(asked).as("the failure path never listed the surviving trees").containsExactly(true); |
| | | assertThat(lockHeld).as("the entry container lock was still held").containsExactly(false); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * A configuration change which leaves the base DNs alone - every change to index-entry-limit, |
| | | * db-cache-percent and the rest - has no storage work to do, so it opens no transaction to |
| | | * commit nothing. |
| | | */ |
| | | @Test |
| | | public void aChangeWhichLeavesTheBaseDNsAloneOpensNoTransaction() throws Exception |
| | | { |
| | | final ReplayingBackend backend = openBackend(newTreeSet(KEPT, REMOVED)); |
| | | try |
| | | { |
| | | final int writesBefore = backend.storage.writes(); |
| | | final ConfigChangeResult ccr = backend.applyConfigurationChange(backendCfg(newTreeSet(KEPT, REMOVED))); |
| | | |
| | | assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS); |
| | | assertThat(ccr.getMessages()).isEmpty(); |
| | | assertThat(backend.storage.writes()).isEqualTo(writesBefore); |
| | | assertThat(backend.getBaseDNs()).contains(KEPT, REMOVED); |
| | | } |
| | | finally |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | } |
| | | |
| | | /** The messages a change result carries, by identity rather than by their formatted text. */ |
| | | private static Set<Integer> ordinalsOf(ConfigChangeResult ccr) |
| | | { |
| | | final Set<Integer> ordinals = new HashSet<>(); |
| | | for (LocalizableMessage message : ccr.getMessages()) |
| | | { |
| | | ordinals.add(message.ordinal()); |
| | | } |
| | | return ordinals; |
| | | } |
| | | |
| | | private static Set<TreeName> treesOf(EntryContainer ec) |
| | | { |
| | | final Set<TreeName> names = new HashSet<>(); |
| | | for (Tree tree : ec.listTrees()) |
| | | { |
| | | names.add(tree.getName()); |
| | | } |
| | | return names; |
| | | } |
| | | |
| | | /** Reads back the flags an index was given when it was opened, as they are stored. */ |
| | | private static EnumSet<IndexFlag> persistedFlags(RootContainer rootContainer, EntryContainer ec, TreeName index) |
| | | throws Exception |
| | | { |
| | | final State state = new State(new TreeName(ec.getTreePrefix(), STATE_INDEX_NAME)); |
| | | return rootContainer.getStorage().read(txn -> state.getIndexFlags(txn, index)); |
| | | } |
| | | |
| | | private ReplayingBackend openBackend(SortedSet<DN> baseDNs) throws Exception |
| | | { |
| | | final ReplayingBackend backend = new ReplayingBackend(); |
| | | backend.setBackendID(BACKEND_ID); |
| | | backend.configuredWith = backendCfg(baseDNs); |
| | | backend.configureBackend(backend.configuredWith, serverContext); |
| | | // Start from a pristine on-disk state so that a previous run cannot mask the defect. |
| | | backend.storage.removeStorageFiles(); |
| | | try |
| | | { |
| | | backend.openBackend(); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | // openBackend() opens the root container before it preloads, counts the entries, registers |
| | | // the base DNs and registers the monitor, so a failure in any of those leaves the volume open |
| | | // and the monitor registered. Every following test would then fail in openBackend() too, and |
| | | // the one which actually broke would be lost among them. |
| | | try |
| | | { |
| | | if (backend.getRootContainer() != null) |
| | | { |
| | | backend.finalizeBackend(); |
| | | } |
| | | else |
| | | { |
| | | backend.storage.close(); |
| | | } |
| | | } |
| | | catch (Exception cleanupFailure) |
| | | { |
| | | // openBackend() registers the root container monitor last of all, and closeBackend() |
| | | // deregisters it without a null check, so cleaning up after a failure before that throws a |
| | | // NullPointerException of its own. The failure being cleaned up after is the one worth |
| | | // reading. |
| | | e.addSuppressed(cleanupFailure); |
| | | } |
| | | throw e; |
| | | } |
| | | return backend; |
| | | } |
| | | |
| | | private PDBBackendCfg backendCfg(SortedSet<DN> baseDNs) 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); |
| | | when(cfg.getDBDirectory()).thenReturn(BACKEND_ID); |
| | | when(cfg.getDBDirectoryPermissions()).thenReturn("755"); |
| | | when(cfg.getDBCacheSize()).thenReturn(0L); |
| | | 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); |
| | | return cfg; |
| | | } |
| | | |
| | | /** A backend whose storage makes the next write operation conflict, and so be replayed. */ |
| | | private static final class ReplayingBackend extends BackendImpl<PDBBackendCfg> |
| | | { |
| | | private ReplayingStorage storage; |
| | | /** The configuration the entry containers register their listeners with. */ |
| | | private PDBBackendCfg configuredWith; |
| | | |
| | | @Override |
| | | protected Storage configureStorage(PDBBackendCfg cfg, ServerContext serverContext) throws ConfigException |
| | | { |
| | | storage = new ReplayingStorage(new PDBStorage(cfg, serverContext)); |
| | | return storage; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * The base DNs a change asks for, which unregisters an entry container the first time it is asked |
| | | * whether it holds one. {@link BackendImpl#applyConfigurationChange} copies the base DNs the root |
| | | * container holds and then looks each of their entry containers up, and asks this set in between; |
| | | * an importLDIF, a rebuildBackend, an exportLDIF or the backend being disabled closes the root |
| | | * container in that window and unregisters every one of them. Done here rather than raced for, so |
| | | * that the window is closed on the same thread every time. |
| | | */ |
| | | private static final class UnregisteringWhenAsked extends TreeSet<DN> |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | private final transient RootContainer rootContainer; |
| | | private final DN toUnregister; |
| | | private boolean unregistered; |
| | | |
| | | UnregisteringWhenAsked(RootContainer rootContainer, DN toUnregister, SortedSet<DN> baseDNs) |
| | | { |
| | | super(baseDNs); |
| | | this.rootContainer = rootContainer; |
| | | this.toUnregister = toUnregister; |
| | | } |
| | | |
| | | @Override |
| | | public boolean contains(Object baseDN) |
| | | { |
| | | if (!unregistered) |
| | | { |
| | | unregistered = true; |
| | | // Closed here because nothing else will: the root container closes the containers it |
| | | // holds, and this one has just been taken out of it, with its configuration listeners |
| | | // still registered. |
| | | closeSilently(rootContainer.unregisterEntryContainer(toUnregister)); |
| | | } |
| | | return super.contains(baseDN); |
| | | } |
| | | } |
| | | |
| | | /** A failure which no storage engine replays, unlike {@link RollbackException}. */ |
| | | private static final class UnreplayableFailure extends Exception |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | } |
| | | |
| | | /** |
| | | * Decorates a {@link Storage} so that the next {@link Storage#write(WriteOperation)} conflicts a |
| | | * given number of times before it is let through. The conflict is raised from within the single |
| | | * {@code write} the delegate is asked for, so the delegate's own retry loop performs the replay. |
| | | */ |
| | | private static final class ReplayingStorage implements Storage |
| | | { |
| | | /** Where the conflict is raised, which decides how much of the operation has run. */ |
| | | private enum ConflictPoint |
| | | { |
| | | /** As soon as the operation first touches the transaction, before it has changed anything. */ |
| | | FIRST_STORAGE_ACCESS, |
| | | /** 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. */ |
| | | NO_REPLAY, |
| | | /** |
| | | * Once the operation has committed, as a failure which is not replayed either: what an engine |
| | | * whose tree deletions do not belong to the transaction leaves behind. |
| | | */ |
| | | NO_REPLAY_AFTER_COMMIT |
| | | } |
| | | |
| | | private final Storage delegate; |
| | | private Runnable onListTrees; |
| | | private ConflictPoint conflictPoint; |
| | | private int conflictsLeft; |
| | | private int attempts; |
| | | private int writes; |
| | | |
| | | ReplayingStorage(Storage delegate) |
| | | { |
| | | this.delegate = delegate; |
| | | } |
| | | |
| | | void conflictAtFirstStorageAccess(int conflicts) |
| | | { |
| | | arm(ConflictPoint.FIRST_STORAGE_ACCESS, conflicts); |
| | | } |
| | | |
| | | void conflictAtCommit(int conflicts) |
| | | { |
| | | arm(ConflictPoint.COMMIT, conflicts); |
| | | } |
| | | |
| | | void failWithoutReplay() |
| | | { |
| | | arm(ConflictPoint.NO_REPLAY, 1); |
| | | } |
| | | |
| | | void failAfterCommit() |
| | | { |
| | | arm(ConflictPoint.NO_REPLAY_AFTER_COMMIT, 1); |
| | | } |
| | | |
| | | private void arm(ConflictPoint where, int conflicts) |
| | | { |
| | | conflictPoint = where; |
| | | conflictsLeft = conflicts; |
| | | attempts = 0; |
| | | } |
| | | |
| | | /** How many times the armed operation was run, the first attempt included. */ |
| | | int attempts() |
| | | { |
| | | return attempts; |
| | | } |
| | | |
| | | /** How many write operations this storage was asked for, armed or not. */ |
| | | int writes() |
| | | { |
| | | return writes; |
| | | } |
| | | |
| | | @Override |
| | | public void write(final WriteOperation writeOperation) throws Exception |
| | | { |
| | | writes++; |
| | | final ConflictPoint armed = conflictPoint; |
| | | if (armed == null) |
| | | { |
| | | delegate.write(writeOperation); |
| | | return; |
| | | } |
| | | conflictPoint = null; |
| | | if (armed == ConflictPoint.NO_REPLAY_AFTER_COMMIT) |
| | | { |
| | | // Committed, then reported as a failure: the operation's work outlives the failure, as it |
| | | // does where the storage engine does not roll a tree deletion back. |
| | | delegate.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | attempts++; |
| | | writeOperation.run(txn); |
| | | } |
| | | }); |
| | | throw new UnreplayableFailure(); |
| | | } |
| | | // A single call, so that the replay is the delegate's own and keeps whatever the delegate |
| | | // holds for the duration of a write, rather than starting afresh as a second call would. |
| | | delegate.write(new WriteOperation() |
| | | { |
| | | @Override |
| | | public void run(WriteableTransaction txn) throws Exception |
| | | { |
| | | attempts++; |
| | | if (conflictsLeft-- <= 0) |
| | | { |
| | | writeOperation.run(txn); |
| | | return; |
| | | } |
| | | if (armed == ConflictPoint.FIRST_STORAGE_ACCESS) |
| | | { |
| | | writeOperation.run(new ConflictingTransaction()); |
| | | return; |
| | | } |
| | | writeOperation.run(txn); |
| | | if (armed == ConflictPoint.NO_REPLAY) |
| | | { |
| | | throw new UnreplayableFailure(); |
| | | } |
| | | throw new RollbackException(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | @Override |
| | | public Importer startImport() throws ConfigException |
| | | { |
| | | return delegate.startImport(); |
| | | } |
| | | |
| | | @Override |
| | | public void open(AccessMode accessMode) throws Exception |
| | | { |
| | | delegate.open(accessMode); |
| | | } |
| | | |
| | | @Override |
| | | public <T> T read(ReadOperation<T> readOperation) throws Exception |
| | | { |
| | | return delegate.read(readOperation); |
| | | } |
| | | |
| | | @Override |
| | | public void removeStorageFiles() |
| | | { |
| | | delegate.removeStorageFiles(); |
| | | } |
| | | |
| | | @Override |
| | | public StorageStatus getStorageStatus() |
| | | { |
| | | return delegate.getStorageStatus(); |
| | | } |
| | | |
| | | @Override |
| | | public boolean supportsBackupAndRestore() |
| | | { |
| | | return delegate.supportsBackupAndRestore(); |
| | | } |
| | | |
| | | @Override |
| | | public void createBackup(BackupConfig backupConfig) throws DirectoryException |
| | | { |
| | | delegate.createBackup(backupConfig); |
| | | } |
| | | |
| | | @Override |
| | | public void removeBackup(BackupDirectory backupDirectory, String backupID) throws DirectoryException |
| | | { |
| | | delegate.removeBackup(backupDirectory, backupID); |
| | | } |
| | | |
| | | @Override |
| | | public void restoreBackup(RestoreConfig restoreConfig) throws DirectoryException |
| | | { |
| | | delegate.restoreBackup(restoreConfig); |
| | | } |
| | | |
| | | /** Run whenever the trees which survived a failure are listed, and only then. */ |
| | | void onListTrees(Runnable probe) |
| | | { |
| | | this.onListTrees = probe; |
| | | } |
| | | |
| | | @Override |
| | | public Set<TreeName> listTrees() |
| | | { |
| | | if (onListTrees != null) |
| | | { |
| | | onListTrees.run(); |
| | | } |
| | | return delegate.listTrees(); |
| | | } |
| | | |
| | | @Override |
| | | public void close() |
| | | { |
| | | delegate.close(); |
| | | } |
| | | } |
| | | |
| | | /** 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() |
| | | { |
| | | return new RollbackException(); |
| | | } |
| | | |
| | | @Override |
| | | public void openTree(TreeName name, boolean createOnDemand) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public void deleteTree(TreeName name) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public void put(TreeName treeName, ByteSequence key, ByteSequence value) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public boolean update(TreeName treeName, ByteSequence key, UpdateFunction f) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public boolean delete(TreeName treeName, ByteSequence key) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public ByteString read(TreeName treeName, ByteSequence key) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public Cursor<ByteString, ByteString> openCursor(TreeName treeName) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public long getRecordCount(TreeName treeName) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | |
| | | @Override |
| | | public boolean treeExists(TreeName treeName) |
| | | { |
| | | throw conflict(); |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | import java.io.IOException; |
| | | import java.util.List; |
| | | import java.util.Locale; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.concurrent.TimeoutException; |
| | | import java.util.concurrent.atomic.AtomicInteger; |
| | | import java.util.function.Predicate; |
| | | |
| | | import org.forgerock.i18n.LocalizableMessage; |
| | | import org.forgerock.opendj.config.server.ConfigException; |
| | |
| | | |
| | | /** {@inheritDoc} */ |
| | | @Override |
| | | public void finalizePlugin() |
| | | { |
| | | /* |
| | | * A park which outlives the test which took it holds a replay thread of this server, |
| | | * and every replayed operation queued behind it, for as long as this plugin is |
| | | * loaded: the map is static and nothing but the test itself removes an entry from it. |
| | | */ |
| | | for (ParkedReplay park : parks.values()) |
| | | { |
| | | park.deregister(); |
| | | } |
| | | parks.clear(); |
| | | } |
| | | |
| | | |
| | | |
| | | /** {@inheritDoc} */ |
| | | @Override |
| | | public PluginResult.PreParse |
| | | doPreParse(PreParseAbandonOperation abandonOperation) |
| | | { |
| | |
| | | } |
| | | |
| | | // Check for registered short circuits. |
| | | final String key = operation.getOperationType() + "/" + section.toLowerCase(); |
| | | final String key = keyFor(operation.getOperationType(), section); |
| | | Integer resultCode = shortCircuits.get(key); |
| | | if (resultCode != null) |
| | | { |
| | |
| | | // operations are let through, which is how a transient failure is simulated. |
| | | } |
| | | |
| | | /* |
| | | * A parked replay is held here, which is inside the run() of the operation and before |
| | | * anything of the backend was taken: the thread which is replaying a change sits on |
| | | * this monitor while it still owns that change, which is what lets a test act on the |
| | | * thread rather than race it. It is consulted last, so that a park never takes an |
| | | * operation away from a control or from a registered short circuit. |
| | | */ |
| | | if (operation.isSynchronizationOperation()) |
| | | { |
| | | final ParkedReplay park = parks.get(key); |
| | | if (park != null && park.parks(operation)) |
| | | { |
| | | final int parkResultCode = park.hold(); |
| | | if (parkResultCode >= 0) |
| | | { |
| | | return parkResultCode; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // If we've gotten here, then we shouldn't short-circuit the operation |
| | | // processing. |
| | | return -1; |
| | |
| | | */ |
| | | public static int getShortCircuitCount(OperationType operation, String section) |
| | | { |
| | | final AtomicInteger count = shortCircuitCounts.get(operation + "/" + section.toLowerCase()); |
| | | final AtomicInteger count = shortCircuitCounts.get(keyFor(operation, section)); |
| | | return count != null ? count.get() : 0; |
| | | } |
| | | |
| | |
| | | */ |
| | | public static void registerShortCircuit(OperationType operation, String section, int resultCode) |
| | | { |
| | | final String key = operation + "/" + section.toLowerCase(); |
| | | final String key = keyFor(operation, section); |
| | | // This registration applies to every operation, and it counts from zero: a limit or |
| | | // a count left behind by a previous registration is not part of it. |
| | | shortCircuitCounts.remove(key); |
| | |
| | | */ |
| | | public static void registerShortCircuit(OperationType operation, String section, int resultCode, int maxTimes) |
| | | { |
| | | final String key = operation + "/" + section.toLowerCase(); |
| | | final String key = keyFor(operation, section); |
| | | shortCircuitCounts.remove(key); |
| | | shortCircuitLimits.put(key, maxTimes); |
| | | shortCircuits.put(key, resultCode); |
| | |
| | | */ |
| | | public static void deregisterShortCircuit(OperationType operation, String section) |
| | | { |
| | | final String key = operation + "/" + section.toLowerCase(); |
| | | final String key = keyFor(operation, section); |
| | | shortCircuits.remove(key); |
| | | shortCircuitLimits.remove(key); |
| | | // The count belongs to the registration which is being removed: a test which counts |
| | | // the operations it short circuits must not inherit the count of the previous one. |
| | | shortCircuitCounts.remove(key); |
| | | } |
| | | |
| | | /** Registered parks for the replayed operations, keyed like the short circuits. */ |
| | | private static final Map<String, ParkedReplay> parks = new ConcurrentHashMap<>(); |
| | | |
| | | /** |
| | | * Holds the replayed operations of one type where they are, one at a time, until the |
| | | * test lets each of them go. |
| | | * <p> |
| | | * The hold is taken at a plugin point which runs inside {@code op.run()}, so the thread |
| | | * which is replaying a change is stopped while it still owns that change: a test can |
| | | * then do something to that thread - stop it, disable its domain - and know the change |
| | | * is in flight rather than hope it is. Nothing of the backend has been taken at that |
| | | * point, so a parked operation blocks the replay and nothing else. |
| | | */ |
| | | public static final class ParkedReplay |
| | | { |
| | | /** |
| | | * The value which lets the operation run rather than short circuit it. |
| | | * <p> |
| | | * {@code ResultCode.UNDEFINED} is registered on {@code -1} as well, so |
| | | * {@code release(ResultCode.UNDEFINED.intValue())} lets the operation run instead of |
| | | * making it report that code - the same hole {@code registerShortCircuit(-1)} has. |
| | | * No caller has a use for it, and a park releases with a real result code or with |
| | | * none at all. |
| | | */ |
| | | private static final int LET_THROUGH = -1; |
| | | |
| | | /** |
| | | * How long an operation is held before this park gives up on the test which took it. |
| | | * <p> |
| | | * It is far longer than any release a test waits for - the fixture itself waits a |
| | | * minute for a park - and it exists for the test which never releases at all: a park |
| | | * leaked by a method killed on a timeout would otherwise hold a replay thread of this |
| | | * server, and every replayed operation queued behind it, for the life of the JVM. |
| | | */ |
| | | private static final long MAX_HOLD_IN_MS = TimeUnit.MINUTES.toMillis(5); |
| | | |
| | | private final String key; |
| | | /** Which of the replayed operations of that type this park is for. */ |
| | | private final Predicate<PluginOperation> parked; |
| | | private final Object lock = new Object(); |
| | | /** Whether an operation is parked right now. */ |
| | | private boolean occupied; |
| | | /** |
| | | * The thread of the operation which parked last. It is never cleared, so that a test |
| | | * which waited for a park is handed the thread of that park even when the operation |
| | | * has left the park since - a park which is let go of by {@link #deregister()}, or by |
| | | * the thread it holds being interrupted, would otherwise hand out no thread at all |
| | | * and have an assertion on which thread replays the change pass without asserting it. |
| | | */ |
| | | private Thread lastParkedThread; |
| | | /** How many operations were parked, which is what tells one park from the next. */ |
| | | private int parkedOperations; |
| | | /** How many of them the test has waited for already. */ |
| | | private int awaitedOperations; |
| | | private boolean released; |
| | | private int releasedResultCode; |
| | | private boolean deregistered; |
| | | |
| | | private ParkedReplay(String key, Predicate<PluginOperation> parked) |
| | | { |
| | | this.key = key; |
| | | this.parked = parked; |
| | | } |
| | | |
| | | /** Returns whether the provided operation is one this park is for. */ |
| | | private boolean parks(PluginOperation operation) |
| | | { |
| | | return parked.test(operation); |
| | | } |
| | | |
| | | /** |
| | | * Parks the calling operation until the test releases it. Runs on the thread which is |
| | | * replaying the change. |
| | | * |
| | | * @return the result code the operation must be short circuited with, or a negative |
| | | * value to let it run |
| | | */ |
| | | private int hold() |
| | | { |
| | | final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(MAX_HOLD_IN_MS); |
| | | synchronized (lock) |
| | | { |
| | | // One operation at a time, so that a release belongs to the operation the test |
| | | // waited for rather than to whichever of them the scheduler let in first. |
| | | while (occupied && !deregistered) |
| | | { |
| | | if (!waitOnLock(deadline)) |
| | | { |
| | | return LET_THROUGH; |
| | | } |
| | | } |
| | | if (deregistered) |
| | | { |
| | | return LET_THROUGH; |
| | | } |
| | | occupied = true; |
| | | lastParkedThread = Thread.currentThread(); |
| | | parkedOperations++; |
| | | released = false; |
| | | lock.notifyAll(); |
| | | try |
| | | { |
| | | while (!released && !deregistered) |
| | | { |
| | | if (!waitOnLock(deadline)) |
| | | { |
| | | return LET_THROUGH; |
| | | } |
| | | } |
| | | return released ? releasedResultCode : LET_THROUGH; |
| | | } |
| | | finally |
| | | { |
| | | occupied = false; |
| | | lock.notifyAll(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Waits on the monitor until the provided deadline, reporting whether waiting can go |
| | | * on. A deadline which has passed gives up on this park altogether rather than only |
| | | * on the operation which reached it: the operations behind it would each pay the |
| | | * whole wait again otherwise. |
| | | */ |
| | | private boolean waitOnLock(long deadlineInNanos) |
| | | { |
| | | final long leftInNanos = deadlineInNanos - System.nanoTime(); |
| | | if (leftInNanos <= 0) |
| | | { |
| | | giveUpOnTheTest(); |
| | | return false; |
| | | } |
| | | try |
| | | { |
| | | // Rounded up, so that a budget shorter than a millisecond is still waited out |
| | | // rather than truncated to a wait with no timeout at all. |
| | | lock.wait(TimeUnit.NANOSECONDS.toMillis(leftInNanos + 999999L)); |
| | | return true; |
| | | } |
| | | catch (InterruptedException e) |
| | | { |
| | | // Whatever wants this thread to stop wins over the park: let the operation run |
| | | // rather than hold a thread which is being taken down. |
| | | Thread.currentThread().interrupt(); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** Stops parking anything and says so, after a test held an operation for too long. */ |
| | | private void giveUpOnTheTest() |
| | | { |
| | | System.err.println("***** ERROR: a replayed operation was parked on " + key |
| | | + " for " + MAX_HOLD_IN_MS + " ms and was never released: the test which took" |
| | | + " this park left it behind. Letting the operation run and parking no more."); |
| | | deregister(); |
| | | } |
| | | |
| | | /** |
| | | * Waits for a replayed operation which was not waited for yet to be parked, and |
| | | * reports which thread is replaying it. The operations are parked one at a time, so |
| | | * that thread is the one which was parked when this returns; the thread of the last |
| | | * park is reported when several of them were let go of without being waited for. |
| | | * |
| | | * @param timeout how long to wait for it |
| | | * @param unit the unit of the timeout |
| | | * @return the thread which is replaying the parked operation |
| | | * @throws InterruptedException if this thread is interrupted while waiting |
| | | * @throws TimeoutException if no operation was parked in time |
| | | * @throws IllegalStateException if this park is gone, so that nothing can be parked |
| | | * on it any more |
| | | */ |
| | | public Thread awaitParked(long timeout, TimeUnit unit) |
| | | throws InterruptedException, TimeoutException |
| | | { |
| | | final long deadline = System.nanoTime() + unit.toNanos(timeout); |
| | | synchronized (lock) |
| | | { |
| | | while (parkedOperations <= awaitedOperations) |
| | | { |
| | | if (deregistered) |
| | | { |
| | | // Waiting out the budget here would report a timeout naming the operations |
| | | // which never parked, rather than the park which cannot park them any more. |
| | | throw new IllegalStateException("the park on " + key + " is gone - it was" |
| | | + " deregistered, or displaced by another park of the same operations -" |
| | | + " so no replayed operation will be parked on it again"); |
| | | } |
| | | final long leftInNanos = deadline - System.nanoTime(); |
| | | if (leftInNanos <= 0) |
| | | { |
| | | throw new TimeoutException("no replayed operation was parked on " + key |
| | | + " within " + timeout + " " + unit); |
| | | } |
| | | // Rounded up, so that a budget shorter than a millisecond is still waited out |
| | | // rather than truncated to a wait with no timeout at all. |
| | | lock.wait(TimeUnit.NANOSECONDS.toMillis(leftInNanos + 999999L)); |
| | | } |
| | | awaitedOperations = parkedOperations; |
| | | return lastParkedThread; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Lets the parked operation run. Valid once {@link #awaitParked} has reported that |
| | | * operation: see there for what a release which arrives before it costs. |
| | | */ |
| | | public void release() |
| | | { |
| | | release(LET_THROUGH); |
| | | } |
| | | |
| | | /** |
| | | * Lets the parked operation go, short circuiting it with the provided result code. |
| | | * <p> |
| | | * Valid once {@link #awaitParked} has reported the operation being released. A |
| | | * release which arrives before an operation is parked is wiped by the park it was |
| | | * meant for - a park starts out unreleased - and that operation then waits for a |
| | | * release which has already been spent. |
| | | * |
| | | * @param resultCode the result code the operation must report |
| | | */ |
| | | public void release(int resultCode) |
| | | { |
| | | synchronized (lock) |
| | | { |
| | | if (!occupied) |
| | | { |
| | | throw new IllegalStateException("nothing is parked on " + key + " to release:" |
| | | + " a release is spent by the park it arrives before, and the operation" |
| | | + " which parks next then waits for one which has already been given"); |
| | | } |
| | | released = true; |
| | | releasedResultCode = resultCode; |
| | | lock.notifyAll(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Stops parking the replayed operations and lets go of the one which is parked, if |
| | | * any. A test must call this however it ends, or it leaves a replay thread of this |
| | | * server parked for good. |
| | | */ |
| | | public void deregister() |
| | | { |
| | | parks.remove(key, this); |
| | | synchronized (lock) |
| | | { |
| | | deregistered = true; |
| | | lock.notifyAll(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Parks the replayed operations of the given type at the given plugin point, until the |
| | | * test releases each of them. |
| | | * |
| | | * @param operation the type of operation to park |
| | | * @param section the plugin point to park them at, which can only be {@code PreParse} |
| | | * @param parked which of them to park - the change a test acts on rather than whatever |
| | | * of that type reaches this point first, which is somebody else's change as |
| | | * soon as more than one of them is in flight |
| | | * @return the park, which the test must {@link ParkedReplay#deregister()} when it is |
| | | * done with it |
| | | * @throws IllegalArgumentException if asked for any plugin point but {@code PreParse} |
| | | */ |
| | | public static ParkedReplay parkReplayedOperations( |
| | | OperationType operation, String section, Predicate<PluginOperation> parked) |
| | | { |
| | | if (!"PreParse".equalsIgnoreCase(section)) |
| | | { |
| | | /* |
| | | * The pre-operation plugins are not invoked for synchronization operations at all, |
| | | * so a park anywhere else is never reached: the test which took it would wait out |
| | | * its whole budget for an operation which cannot park, and be told that none did |
| | | * rather than that none could. |
| | | */ |
| | | throw new IllegalArgumentException("replayed operations can only be parked at" |
| | | + " PreParse, which is the only plugin point they reach, not at " + section); |
| | | } |
| | | final String key = keyFor(operation, section); |
| | | final ParkedReplay park = new ParkedReplay(key, parked); |
| | | final ParkedReplay previous = parks.put(key, park); |
| | | if (previous != null) |
| | | { |
| | | // A park a test left behind holds a replay thread of this server for good once the |
| | | // map stops pointing at it: let go of it rather than lose the last reference to it. |
| | | previous.deregister(); |
| | | } |
| | | return park; |
| | | } |
| | | |
| | | /** Returns the key a short circuit or a park of the given operations is kept under. */ |
| | | private static String keyFor(OperationType operation, String section) |
| | | { |
| | | return operation + "/" + section.toLowerCase(Locale.ROOT); |
| | | } |
| | | } |
| | |
| | | /** Generation id for a fully empty domain. */ |
| | | public static final long EMPTY_DN_GENID = GenerationIdChecksum.EMPTY_BACKEND_GENERATION_ID; |
| | | |
| | | /** The group a replication server and a replication domain are in unless told otherwise. */ |
| | | protected static final int DEFAULT_GROUP_ID = 1; |
| | | |
| | | /** |
| | | * The group of a broker which is in none. Assured replication does not cross group ids, |
| | | * so such a broker is never waited for and never waits: it is all a broker which only |
| | | * publishes and reads updates needs. |
| | | */ |
| | | private static final int NO_GROUP_ID = -1; |
| | | |
| | | /** How many times {@link #assertMonitorAttrValueStays} reads a value by default. */ |
| | | private static final int MONITOR_ATTR_SAMPLES = 5; |
| | | |
| | |
| | | int serverId, int windowSize, int port, int timeout, |
| | | long generationId) throws Exception |
| | | { |
| | | final DomainFakeCfg config = newFakeCfg(baseDN, serverId, port); |
| | | return openReplicationSession( |
| | | newFakeCfg(baseDN, serverId, port), windowSize, timeout, generationId); |
| | | } |
| | | |
| | | /** |
| | | * Open a session to the local ReplicationServer which takes part in assured replication. |
| | | * <p> |
| | | * Assured replication does not cross group ids, so a broker whose updates are to be |
| | | * acknowledged by the replicas of this server has to be in the group of the replication |
| | | * server: an update published by a broker of another group is acknowledged on the spot, |
| | | * by the replication server itself, and says nothing about what any replica did with it. |
| | | * <p> |
| | | * The group cuts both ways, and this broker does not acknowledge anything: the |
| | | * replication server expects an ack from every replica of its group whatever that |
| | | * replica is configured for, so a SAFE_READ update published by anyone else while this |
| | | * broker is connected waits out the {@code assured-timeout} of the server. Publish the |
| | | * assured updates from this broker, and open only one of them. |
| | | * |
| | | * @param baseDN the suffix the session is opened for |
| | | * @param serverId the id this broker takes |
| | | * @param windowSize the window size of the session |
| | | * @param port the port of the local replication server |
| | | * @param timeout the read timeout of the session, or 0 for none |
| | | * @return the connected broker |
| | | * @throws Exception if the session could not be opened |
| | | */ |
| | | protected ReplicationBroker openAssuredReplicationSession(final DN baseDN, |
| | | int serverId, int windowSize, int port, int timeout) throws Exception |
| | | { |
| | | return openReplicationSession(newFakeCfg(baseDN, serverId, port, DEFAULT_GROUP_ID), |
| | | windowSize, timeout, getGenerationId(baseDN)); |
| | | } |
| | | |
| | | private ReplicationBroker openReplicationSession(final DomainFakeCfg config, |
| | | int windowSize, int timeout, long generationId) throws Exception |
| | | { |
| | | config.setWindowSize(windowSize); |
| | | |
| | | final ReplicationBroker broker = new ReplicationBroker( |
| | |
| | | |
| | | protected DomainFakeCfg newFakeCfg(final DN baseDN, int serverId, int port) |
| | | { |
| | | DomainFakeCfg fakeCfg = new DomainFakeCfg(baseDN, serverId, newTreeSet("127.0.0.1:" + port)); |
| | | return newFakeCfg(baseDN, serverId, port, NO_GROUP_ID); |
| | | } |
| | | |
| | | protected DomainFakeCfg newFakeCfg(final DN baseDN, int serverId, int port, int groupId) |
| | | { |
| | | DomainFakeCfg fakeCfg = |
| | | new DomainFakeCfg(baseDN, serverId, newTreeSet("127.0.0.1:" + port), groupId); |
| | | fakeCfg.setHeartbeatInterval(100000); |
| | | fakeCfg.setChangetimeHeartbeatInterval(500); |
| | | return fakeCfg; |
| | |
| | | import static org.forgerock.opendj.ldap.ModificationType.*; |
| | | import static org.forgerock.opendj.ldap.requests.Requests.*; |
| | | import static org.forgerock.opendj.ldap.schema.CoreSchema.*; |
| | | import static org.mockito.Mockito.*; |
| | | import static org.opends.server.TestCaseUtils.*; |
| | | import static org.opends.server.protocols.internal.InternalClientConnection.*; |
| | | import static org.opends.server.replication.plugin.LDAPReplicationDomain.*; |
| | |
| | | import java.net.SocketTimeoutException; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.concurrent.TimeoutException; |
| | | import java.util.concurrent.atomic.AtomicReference; |
| | | |
| | | import org.assertj.core.api.Assertions; |
| | | import org.forgerock.i18n.LocalizableMessage; |
| | |
| | | import org.forgerock.opendj.ldap.requests.ModifyDNRequest; |
| | | import org.forgerock.opendj.ldap.requests.ModifyRequest; |
| | | import org.forgerock.opendj.ldap.schema.AttributeType; |
| | | import org.forgerock.opendj.server.config.server.ReplicationSynchronizationProviderCfg; |
| | | import org.opends.server.TestCaseUtils; |
| | | import org.opends.server.core.AddOperation; |
| | | import org.opends.server.core.DeleteOperation; |
| | |
| | | import org.opends.server.core.ModifyOperationBasis; |
| | | import org.opends.server.extensions.DummyAlertHandler; |
| | | import org.opends.server.plugins.ShortCircuitPlugin; |
| | | import org.opends.server.plugins.ShortCircuitPlugin.ParkedReplay; |
| | | import org.opends.server.protocols.internal.InternalClientConnection; |
| | | import org.opends.server.replication.common.AssuredMode; |
| | | import org.opends.server.replication.common.CSN; |
| | | import org.opends.server.replication.common.CSNGenerator; |
| | | import org.opends.server.replication.plugin.LDAPReplicationDomain; |
| | | import org.opends.server.replication.plugin.MultimasterReplication; |
| | | import org.opends.server.replication.protocol.AckMsg; |
| | | import org.opends.server.replication.protocol.AddMsg; |
| | | import org.opends.server.replication.protocol.DeleteMsg; |
| | | import org.opends.server.replication.protocol.HeartbeatThread; |
| | |
| | | + "cn: Replication Server\n" |
| | | + "ds-cfg-replication-port: " + replServerPort + "\n" |
| | | + "ds-cfg-replication-db-directory: UpdateOperationTest\n" |
| | | + "ds-cfg-replication-server-id: 107\n"; |
| | | + "ds-cfg-replication-server-id: 107\n" |
| | | /* |
| | | * Long enough for a delivery which a test stops on its way through the replay: |
| | | * the acks of an assured update are waited for from the moment it is published, |
| | | * and the default second is spent long before a test which parks that delivery |
| | | * has let go of it. Nothing waits it out - no test here leaves an assured update |
| | | * unacknowledged - so it only bounds a failure. |
| | | */ |
| | | + "ds-cfg-assured-timeout: 120000ms\n"; |
| | | |
| | | // suffix synchronized |
| | | String testName = "updateOperationTest"; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Test case for [Issue 909]: a replay thread which is stopped while it holds a change - |
| | | * the number of replay threads is changed on a live server - must hand that change back |
| | | * to the replication server instead of leaving it listed as owned by a thread which is |
| | | * gone. |
| | | * <p> |
| | | * The change is parked inside the operation it is replayed by, so the thread is caught |
| | | * while it still owns it rather than raced for: a change which is released by the |
| | | * ordinary recovery instead ends the same way - delivered again and applied - so a test |
| | | * which only watched the end state would pass whether or not the hand-back happened. |
| | | * <p> |
| | | * What tells them apart is which thread replays the change next. The thread which held |
| | | * it is gone, and the change is replayed by one of the threads which replaced it, so |
| | | * the delivery it is replayed from can only be a new one: an attempt which the same |
| | | * thread made again would be the retry in place, and a change nobody handed back is |
| | | * never delivered again at all - it stays listed as owned by a thread which is gone, |
| | | * with the ServerState of this domain stopped behind it for good. |
| | | */ |
| | | @Test |
| | | public void aChangeAStoppedReplayThreadHeldIsGivenBackAndDeliveredAgain() throws Exception |
| | | { |
| | | testSetUp("aChangeAStoppedReplayThreadHeldIsGivenBackAndDeliveredAgain"); |
| | | logger.error(LocalizableMessage.raw( |
| | | "Starting replication test : aChangeAStoppedReplayThreadHeldIsGivenBackAndDeliveredAgain")); |
| | | |
| | | final int serverId = 14; |
| | | /* |
| | | * In the group of the replication server, so that the delete published below is one |
| | | * this domain has to acknowledge: an assured update from a broker of another group is |
| | | * acknowledged by the replication server itself, and says nothing about the replay. |
| | | */ |
| | | ReplicationBroker broker = |
| | | openAssuredReplicationSession(baseDN, serverId, 100, replServerPort, 1000); |
| | | try |
| | | { |
| | | CSNGenerator gen = new CSNGenerator(serverId, 0); |
| | | |
| | | Entry tmp = TestCaseUtils.addEntry( |
| | | "dn: uid=user.909," + baseDN, |
| | | "objectClass: top", |
| | | "objectClass: person", |
| | | "objectClass: organizationalPerson", |
| | | "objectClass: inetOrgPerson", |
| | | "uid: user.909", |
| | | "cn: Aaccf Amar", |
| | | "sn: Amar"); |
| | | String uuid = getEntry(tmp.getName(), 1, true).parseAttribute("entryuuid").asString(); |
| | | |
| | | final LDAPReplicationDomain domain = MultimasterReplication.findDomain(baseDN, null); |
| | | domain.resetUnreplayedChangeAlertThrottle(); |
| | | final int initialAlerts = DummyAlertHandler.getAlertCount(ALERT_TYPE_REPLICATION_UNREPLAYED_CHANGE); |
| | | /* |
| | | * Only the counters of the replay are read: a session restart takes this domain |
| | | * through NOT_CONNECTED, which resets every monitoring counter of the replication |
| | | * service - the updates it received and processed, and the assured acks it sent - |
| | | * and handing a change back is a session restart. |
| | | */ |
| | | final long initialApplied = getMonitorAttrValue(baseDN, "replayed-updates-ok"); |
| | | final long initialFailures = getMonitorAttrValue(baseDN, "replayed-updates-failed"); |
| | | |
| | | /* |
| | | * Hold the replayed deletes where they are. The park is taken at the pre-parse |
| | | * plugin point, which runs inside op.run() and before anything of the backend was |
| | | * taken: the replay thread stops there while it still owns the change, and the |
| | | * pre-operation plugins are not invoked for synchronization operations at all. |
| | | */ |
| | | final CSN csn = gen.newCSN(); |
| | | final ParkedReplay parked = ShortCircuitPlugin.parkReplayedOperations( |
| | | OperationType.DELETE, "PreParse", op -> csn.equals(OperationContext.getCSN(op))); |
| | | final AtomicReference<Throwable> reconfigurationFailure = new AtomicReference<>(); |
| | | Thread reconfiguration = null; |
| | | boolean reconfigurationFinished = true; |
| | | try |
| | | { |
| | | final DeleteMsg delete = new DeleteMsg(tmp.getName(), csn, uuid); |
| | | /* |
| | | * Published assured in SAFE_READ mode, so that the delivery which is abandoned |
| | | * has to say what it did. The ack is the one thing the counters cannot report |
| | | * afterwards - the session restart the hand-back performs resets every one of |
| | | * them - so it is read off this broker rather than counted. |
| | | */ |
| | | delete.setAssured(true); |
| | | delete.setAssuredMode(AssuredMode.SAFE_READ_MODE); |
| | | broker.publish(delete); |
| | | |
| | | // A replay thread now owns the change and is stopped inside its operation. |
| | | final Thread abandoningThread = parked.awaitParked(60, SECONDS); |
| | | |
| | | /* |
| | | * Change the number of replay threads while that thread holds the change. It runs |
| | | * on a thread of its own because stopping the replay threads joins them: it can |
| | | * not return before the parked thread is let go, which is exactly the ordering |
| | | * this test is about. |
| | | */ |
| | | reconfiguration = startReplayThreadReconfiguration(2, reconfigurationFailure); |
| | | awaitStoppingTheReplayThreads(reconfiguration, reconfigurationFailure); |
| | | |
| | | /* |
| | | * The parked thread has been asked to stop by now, so its attempt comes back on a |
| | | * storage which did not serve the operation - which is retried in place - and the |
| | | * attempt which follows is where it finds out that it is going away and gives the |
| | | * change back instead. |
| | | */ |
| | | parked.release(ResultCode.UNAVAILABLE.intValue()); |
| | | |
| | | /* |
| | | * The change was given back, so the replication server owns it again and delivers |
| | | * it once more. This second park is where the state of the domain is read: the |
| | | * change is held inside its operation, so nothing can be recording it while the |
| | | * assertions below run. |
| | | */ |
| | | final Thread replayingThread = |
| | | awaitParkedOrReportReconfigurationFailure(parked, reconfigurationFailure); |
| | | reconfiguration.join(SECONDS.toMillis(60)); |
| | | assertFalse(reconfiguration.isAlive(), |
| | | "the replay threads were reconfigured, but applyConfigurationChange never returned"); |
| | | if (reconfigurationFailure.get() != null) |
| | | { |
| | | throw new AssertionError("the replay threads could not be reconfigured", |
| | | reconfigurationFailure.get()); |
| | | } |
| | | |
| | | /* |
| | | * The change is being replayed by another thread, and the thread which held it is |
| | | * gone: it gave the change back on its way out rather than take it with it. An |
| | | * attempt made by the thread which held it would be the retry in place instead, |
| | | * and a change which was never handed back is not replayed by anyone. |
| | | */ |
| | | assertFalse(abandoningThread.isAlive(), |
| | | "the replay thread which held the change must have stopped"); |
| | | Assertions.assertThat(replayingThread) |
| | | .as("the change must be replayed by a thread which did not hold it") |
| | | .isNotSameAs(abandoningThread); |
| | | |
| | | assertFalse(domain.getServerState().cover(csn), |
| | | "a change a stopped replay thread never applied must not be in the ServerState"); |
| | | assertEquals(getMonitorAttrValue(baseDN, "replayed-updates-failed"), initialFailures, |
| | | "a change which was handed back must not be counted as one this replica gave up on"); |
| | | assertNotNull(getEntry(tmp.getName(), 1, true), |
| | | "the entry must not have been deleted by the delivery which was abandoned"); |
| | | |
| | | /* |
| | | * The delivery which was abandoned said what it did: an assured write which is |
| | | * waiting on this replica must not be told that the change is in the data here, |
| | | * because it is a change this replica is still asking for. |
| | | * |
| | | * What is read is the ack, not when it was published. The hand-back gives this |
| | | * domain a new broker and the replication server waits for an ack by CSN rather |
| | | * than by session, so an ack published after the hand-back reaches it all the |
| | | * same: the order of the two is not what this asserts. |
| | | */ |
| | | final AckMsg ack = awaitAck(broker, csn); |
| | | assertTrue(ack.hasReplayError(), |
| | | "the ack of the abandoned delivery must report the replay error rather than" |
| | | + " be the plain ack a master would take for a durable write"); |
| | | assertFalse(ack.hasTimeout(), |
| | | "the ack must be the one the abandoned delivery published, not the one the" |
| | | + " replication server makes up when it gives up waiting for it"); |
| | | Assertions.assertThat(ack.getFailedServers()) |
| | | .as("the replica which abandoned the delivery must be the one it names") |
| | | .containsExactly(domainSid); |
| | | |
| | | /* |
| | | * Let the delivery which took over apply the change, and stop parking: an attempt |
| | | * of that delivery which came back on a lock it could not take would be parked |
| | | * again otherwise, with nothing left to release it. |
| | | */ |
| | | parked.deregister(); |
| | | |
| | | assertMonitorAttrValueEventually(baseDN, "replayed-updates-ok", initialApplied + 1, |
| | | "the change must be applied by the delivery which took over from the abandoned one"); |
| | | /* |
| | | * A change applied twice goes through the expected count on its way up, so the |
| | | * value has to be seen to stay put rather than to be reached once - and for |
| | | * longer than the session restart which would bring that second delivery, or the |
| | | * assertion stops looking before what it is looking for could arrive. |
| | | */ |
| | | assertMonitorAttrValueStays(baseDN, "replayed-updates-ok", initialApplied + 1, |
| | | MONITOR_ATTR_SAMPLES_ACROSS_A_REDELIVERY, |
| | | "the change must be applied exactly once"); |
| | | assertNull(DirectoryServer.getEntry(tmp.getName()), "the entry must have been deleted"); |
| | | |
| | | /* |
| | | * Abandoning a change is not giving up on it: the change is applied moments later, |
| | | * so nothing is counted as failed and the administrator is not told that this |
| | | * replica diverges. |
| | | */ |
| | | assertMonitorAttrValueStays(baseDN, "replayed-updates-failed", initialFailures, |
| | | MONITOR_ATTR_SAMPLES_ACROSS_A_REDELIVERY, |
| | | "a change which was handed back must not be counted as one this replica gave up on"); |
| | | assertEquals(DummyAlertHandler.getAlertCount(ALERT_TYPE_REPLICATION_UNREPLAYED_CHANGE), |
| | | initialAlerts, |
| | | "a change which was handed back must not have this replica report a divergence"); |
| | | } |
| | | finally |
| | | { |
| | | /* |
| | | * Whatever happened above: no replay thread of this server may be left parked, |
| | | * and the reconfiguration has to be over before the next one is started. The two |
| | | * would otherwise race for the pool of replay threads, which belongs to the |
| | | * server rather than to this test, and the one which creates its threads last |
| | | * drops the ones the other had just started without ever stopping them. |
| | | */ |
| | | parked.deregister(); |
| | | if (reconfiguration != null) |
| | | { |
| | | reconfiguration.join(SECONDS.toMillis(60)); |
| | | reconfigurationFinished = !reconfiguration.isAlive(); |
| | | } |
| | | if (reconfigurationFinished) |
| | | { |
| | | setNumberOfReplayThreads(null); |
| | | } |
| | | } |
| | | /* |
| | | * Read here rather than asserted while cleaning up, where a failure would replace |
| | | * the one the test was reporting. join() with a timeout returns the same whether |
| | | * the thread is over or not, and the pool of replay threads belongs to the server: |
| | | * a reconfiguration still inside stopReplayThreads() holds the monitor of |
| | | * MultimasterReplication, which restoring the pool would wait on for as long as an |
| | | * untimed join() takes, and one caught between stopping and creating orphans the |
| | | * threads the restore had just started. So the pool is left alone and this says so. |
| | | */ |
| | | assertTrue(reconfigurationFinished, |
| | | "the replay thread reconfiguration never finished; the pool was left alone"); |
| | | } |
| | | finally |
| | | { |
| | | broker.stop(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Starts changing the number of replay threads of this server on a thread of its own. |
| | | * <p> |
| | | * It cannot run on the thread of the test: stopping the replay threads joins them, so it |
| | | * does not return while one of them is parked in a change, which is the whole point of |
| | | * this fixture. |
| | | * |
| | | * @param replayThreads how many replay threads the server must run, or {@code null} for |
| | | * as many as it computes on its own |
| | | * @param failure where the reconfiguration reports what it ran into, if anything |
| | | * @return the thread which is doing the reconfiguration |
| | | */ |
| | | private static Thread startReplayThreadReconfiguration( |
| | | final Integer replayThreads, final AtomicReference<Throwable> failure) |
| | | { |
| | | final Thread reconfiguration = new Thread(new Runnable() |
| | | { |
| | | @Override |
| | | public void run() |
| | | { |
| | | try |
| | | { |
| | | setNumberOfReplayThreads(replayThreads); |
| | | } |
| | | catch (Throwable t) |
| | | { |
| | | failure.set(t); |
| | | } |
| | | } |
| | | }, "replay thread reconfiguration"); |
| | | // A reconfiguration which never returns holds the monitor of MultimasterReplication: |
| | | // let the fork end on it rather than have it kept alive by a thread of this test. |
| | | reconfiguration.setDaemon(true); |
| | | reconfiguration.start(); |
| | | return reconfiguration; |
| | | } |
| | | |
| | | /** |
| | | * Reads the acknowledgement of the provided change off the broker it was published on. |
| | | * <p> |
| | | * The messages which come first are discarded: this broker is told about everything the |
| | | * replication server has for it, and the ack of one change is what is being looked for. |
| | | * |
| | | * @param broker the broker the change was published on |
| | | * @param csn the change the ack is expected for |
| | | * @return the ack of that change |
| | | * @throws Exception if it never arrived |
| | | */ |
| | | private static AckMsg awaitAck(final ReplicationBroker broker, final CSN csn) throws Exception |
| | | { |
| | | final long deadline = System.nanoTime() + SECONDS.toNanos(60); |
| | | while (deadline - System.nanoTime() > 0) |
| | | { |
| | | final ReplicationMsg msg; |
| | | try |
| | | { |
| | | msg = broker.receive(); |
| | | } |
| | | catch (SocketTimeoutException e) |
| | | { |
| | | // The broker reads under a timeout of its own, which is far shorter than the |
| | | // budget here: a quiet second is not an answer. |
| | | continue; |
| | | } |
| | | if (msg == null) |
| | | { |
| | | // The broker stopped rather than timed out: there is nothing left to read from, |
| | | // and reading it again would spin a core for the rest of the budget. |
| | | throw new AssertionError("the session " + csn + " was published on is gone," |
| | | + " so the ack of that change can no longer arrive"); |
| | | } |
| | | if (msg instanceof AckMsg && csn.equals(((AckMsg) msg).getCSN())) |
| | | { |
| | | return (AckMsg) msg; |
| | | } |
| | | } |
| | | throw new AssertionError("the delivery of " + csn + " was never acknowledged"); |
| | | } |
| | | |
| | | /** |
| | | * Waits for the delivery which took over from the abandoned one to be parked, reporting |
| | | * what the reconfiguration ran into when that is why nothing was parked. |
| | | * <p> |
| | | * A reconfiguration which throws once it has stopped the replay threads leaves this |
| | | * server with no replay thread at all: nothing can be parked then, and the timeout of |
| | | * the wait would be reported in place of the failure which brought it about. |
| | | * |
| | | * @param parked the park the delivery is expected to be caught in |
| | | * @param failure where the reconfiguration reports what it ran into, if anything |
| | | * @return the thread which is replaying the parked operation |
| | | * @throws Exception if no operation was parked in time |
| | | */ |
| | | private static Thread awaitParkedOrReportReconfigurationFailure( |
| | | final ParkedReplay parked, final AtomicReference<Throwable> failure) throws Exception |
| | | { |
| | | try |
| | | { |
| | | return parked.awaitParked(60, SECONDS); |
| | | } |
| | | catch (TimeoutException e) |
| | | { |
| | | final Throwable cause = failure.get(); |
| | | if (cause == null) |
| | | { |
| | | throw e; |
| | | } |
| | | final AssertionError error = new AssertionError( |
| | | "the replay threads could not be reconfigured, so nothing was left to replay" |
| | | + " the change which was handed back", cause); |
| | | error.addSuppressed(e); |
| | | throw error; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Changes the number of replay threads of this server, the way a change of |
| | | * {@code num-update-replay-threads} does on a live server. |
| | | * |
| | | * @param replayThreads how many replay threads the server must run, or {@code null} for |
| | | * as many as it computes on its own |
| | | */ |
| | | private static void setNumberOfReplayThreads(final Integer replayThreads) |
| | | { |
| | | final ReplicationSynchronizationProviderCfg cfg = |
| | | mock(ReplicationSynchronizationProviderCfg.class); |
| | | /* |
| | | * An unstubbed mock hands out 0 replay threads and no connection timeout, and both |
| | | * would outlive this test: the whole server shares one pool of replay threads. The |
| | | * number of them is what this test is changing; the connection timeout is read back |
| | | * from the running server, so that it is restored rather than restated. |
| | | */ |
| | | when(cfg.getNumUpdateReplayThreads()).thenReturn(replayThreads); |
| | | when(cfg.getConnectionTimeout()) |
| | | .thenReturn((long) MultimasterReplication.getConnectionTimeoutMS()); |
| | | multimasterReplication().applyConfigurationChange(cfg); |
| | | } |
| | | |
| | | /** Returns the replication synchronization provider of the running server. */ |
| | | private static MultimasterReplication multimasterReplication() |
| | | { |
| | | // Read as an Object: the provider is declared with the configuration of its own type, |
| | | // which is not the one the registry lists. |
| | | for (Object provider : DirectoryServer.getSynchronizationProviders()) |
| | | { |
| | | if (provider instanceof MultimasterReplication) |
| | | { |
| | | return (MultimasterReplication) provider; |
| | | } |
| | | } |
| | | throw new AssertionError("this server runs no replication synchronization provider"); |
| | | } |
| | | |
| | | /** |
| | | * Waits for the provided thread to be inside the {@code join()} of the replay threads it |
| | | * is stopping. |
| | | * <p> |
| | | * Waiting for that, rather than for the thread to be started, is what puts the change in |
| | | * the hands of a thread which has already been asked to stop: every replay thread is |
| | | * asked to stop before the first of them is joined, so a thread which is joining has |
| | | * asked the parked one. Where the thread waits is checked as well as that it waits: the |
| | | * state on its own would be satisfied by any wait at all, including one taken before the |
| | | * replay threads were asked to stop. |
| | | * |
| | | * @param reconfiguration the thread which is changing the number of replay threads |
| | | * @param failure where that thread reports what it ran into, if anything |
| | | * @throws Exception if it never reached the join |
| | | */ |
| | | private static void awaitStoppingTheReplayThreads( |
| | | final Thread reconfiguration, final AtomicReference<Throwable> failure) throws Exception |
| | | { |
| | | final long deadline = System.nanoTime() + SECONDS.toNanos(60); |
| | | while (deadline - System.nanoTime() > 0) |
| | | { |
| | | if (reconfiguration.getState() == Thread.State.WAITING |
| | | && isJoiningTheReplayThreads(reconfiguration)) |
| | | { |
| | | return; |
| | | } |
| | | if (!reconfiguration.isAlive()) |
| | | { |
| | | if (failure.get() != null) |
| | | { |
| | | throw new AssertionError( |
| | | "the replay threads could not be reconfigured", failure.get()); |
| | | } |
| | | fail("the replay threads were stopped without joining the one which holds a change"); |
| | | } |
| | | // Sampling the stack of a running thread costs a handshake with it, so it is done |
| | | // often enough to open the gate promptly and not so often as to slow the server |
| | | // this test is watching: the thread it waits for is not going anywhere. |
| | | Thread.sleep(10); |
| | | } |
| | | fail("the reconfiguration never reached the join() of the replay threads"); |
| | | } |
| | | |
| | | /** Returns whether the provided thread is waiting on the replay threads it stopped. */ |
| | | private static boolean isJoiningTheReplayThreads(final Thread reconfiguration) |
| | | { |
| | | for (final StackTraceElement frame : reconfiguration.getStackTrace()) |
| | | { |
| | | if ("stopReplayThreads".equals(frame.getMethodName()) |
| | | && MultimasterReplication.class.getName().equals(frame.getClassName())) |
| | | { |
| | | return true; |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Test case for [Issue 889]: every change which can not be replayed must be given up |
| | | * on, not only the one which fails on its own. |
| | | * <p> |
| New file |
| | |
| | | /* |
| | | * 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 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.replication.plugin; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.opends.server.replication.plugin.LDAPReplicationDomain.isServerFailure; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.Arrays; |
| | | import java.util.List; |
| | | |
| | | import org.assertj.core.api.SoftAssertions; |
| | | import org.forgerock.opendj.ldap.ResultCode; |
| | | import org.opends.server.DirectoryServerTestCase; |
| | | import org.testng.annotations.DataProvider; |
| | | import org.testng.annotations.Test; |
| | | |
| | | /** |
| | | * Tests which result codes {@link LDAPReplicationDomain} counts as a failure of the server |
| | | * rather than as a change only conflict resolution can apply. |
| | | * <p> |
| | | * {@code ds-cfg-server-error-result-code} is a plain integer which is not validated as a |
| | | * result code, so an administrator can set it to a code {@code solveNamingConflict()} owns. |
| | | * The replay must still leave such a change to conflict resolution: a replica which took |
| | | * every one of them for a failure of the server would retry them in place, hold its |
| | | * ServerState back over them and give up on them once the give-up delay is spent - for |
| | | * conflicts conflict resolution would have solved on the attempt it never got. |
| | | * <p> |
| | | * Only {@code UNWILLING_TO_PERFORM} has an end-to-end test of that rule, because each of the |
| | | * other codes would need a scenario which fails with exactly that code and which nothing but |
| | | * conflict resolution can apply - and {@code OBJECTCLASS_VIOLATION} has none a replayed |
| | | * operation reaches through the server's own paths, the schema checks which raise it being |
| | | * skipped for synchronization operations. Forcing a code with {@code ShortCircuitPlugin} does |
| | | * not stand in for one: the operation applies for real once that budget is spent, so such a |
| | | * test passes with the code removed from the set (#910, #938). So the rule is pinned here on |
| | | * the predicate itself, with no server in it. |
| | | * <p> |
| | | * The codes below are written out rather than read from {@code CONFLICT_RESULT_CODES}: a data |
| | | * provider fed by the set under test agrees with it whatever it holds, which is exactly what |
| | | * leaves the set unguarded today. Every result code which can be configured is then swept |
| | | * through the predicate against that list, so a code put into the set is as visible as one |
| | | * taken out of it, and the set itself stays private - nothing here reads it. |
| | | * <p> |
| | | * What this can not see is the set drifting away from the branches of |
| | | * {@code solveNamingConflict()} it is the union of: a code dropped from both at once leaves |
| | | * the table below still describing the predicate correctly. |
| | | * <p> |
| | | * Nor is the predicate the whole of the rule at the call site: {@code replay()} answers |
| | | * {@code SUCCESS} and {@code NO_OPERATION} before it consults this at all, so a change which |
| | | * came back with one of them never reaches the table below whatever is configured - that is |
| | | * issue #953. {@code BUSY} is answered ahead of it too, but only inside the retry loop: once |
| | | * the in-place attempts are spent, the predicate is asked about it after all. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | public class IsServerFailureTest extends DirectoryServerTestCase |
| | | { |
| | | /** |
| | | * The result codes the four {@code solveNamingConflict()} overloads dispatch on. Not |
| | | * everything they resolve: the ModifyDN overload answers {@code NOTHING_TO_DO} from three |
| | | * early returns before it ever looks at the result code. |
| | | */ |
| | | private static final List<ResultCode> CONFLICT_CODES = Arrays.asList( |
| | | ResultCode.NO_SUCH_OBJECT, // 32, all four overloads |
| | | ResultCode.ENTRY_ALREADY_EXISTS, // 68, the Add and ModifyDN overloads |
| | | ResultCode.NOT_ALLOWED_ON_RDN, // 67, the Modify overload |
| | | ResultCode.NOT_ALLOWED_ON_NONLEAF, // 66, the Delete overload |
| | | ResultCode.UNWILLING_TO_PERFORM, // 53, the ModifyDN overload |
| | | ResultCode.OBJECTCLASS_VIOLATION); // 65, the ModifyDN overload |
| | | |
| | | /** |
| | | * Codes conflict resolution does not solve. {@code OTHER} is the default of |
| | | * {@code ds-cfg-server-error-result-code}, and the property is an integer with no upper |
| | | * limit, so a value no result code is named after is a setting like any other. |
| | | * {@code UNAVAILABLE} is not here: it is a failure of the server whatever is configured, |
| | | * which {@link #unavailableIsAServerFailureWhateverIsConfigured()} covers. |
| | | */ |
| | | private static final List<ResultCode> CODES_OUTSIDE_THE_CONFLICT_SET = Arrays.asList( |
| | | ResultCode.OTHER, // 80, the default |
| | | ResultCode.CONSTRAINT_VIOLATION, // 19 |
| | | ResultCode.INSUFFICIENT_ACCESS_RIGHTS, // 50 |
| | | ResultCode.valueOf(9999)); // a value no result code is named after |
| | | |
| | | @DataProvider(name = "conflictResultCodes") |
| | | public Object[][] conflictResultCodes() |
| | | { |
| | | return rowsOf(CONFLICT_CODES); |
| | | } |
| | | |
| | | @DataProvider(name = "codesOutsideTheConflictSet") |
| | | public Object[][] codesOutsideTheConflictSet() |
| | | { |
| | | return rowsOf(CODES_OUTSIDE_THE_CONFLICT_SET); |
| | | } |
| | | |
| | | /** |
| | | * Every result code an administrator could put in {@code ds-cfg-server-error-result-code}. |
| | | * The property is an integer with {@code lower-limit="0"}, so {@code UNDEFINED} (-1) is not |
| | | * one of them - it is the null object of {@link ResultCode} rather than a setting - and a |
| | | * value no result code is named after is. |
| | | */ |
| | | private static List<ResultCode> configurableCodes() |
| | | { |
| | | final List<ResultCode> codes = new ArrayList<>(); |
| | | for (ResultCode code : ResultCode.values()) |
| | | { |
| | | if (code.intValue() >= 0) |
| | | { |
| | | codes.add(code); |
| | | } |
| | | } |
| | | codes.add(ResultCode.valueOf(9999)); |
| | | return codes; |
| | | } |
| | | |
| | | private static Object[][] rowsOf(List<ResultCode> codes) |
| | | { |
| | | final Object[][] rows = new Object[codes.size()][]; |
| | | for (int i = 0; i < codes.size(); i++) |
| | | { |
| | | rows[i] = new Object[] { codes.get(i) }; |
| | | } |
| | | return rows; |
| | | } |
| | | |
| | | /** A result which is neither {@code UNAVAILABLE} nor the configured code. */ |
| | | @DataProvider(name = "resultAndAnotherConfiguredCode") |
| | | public Object[][] resultAndAnotherConfiguredCode() |
| | | { |
| | | return new Object[][] { |
| | | // a conflict under the default setting: the carve-out for CONFLICT_RESULT_CODES does |
| | | // not come into it, the result simply is not what this server puts on an internal error |
| | | { ResultCode.NO_SUCH_OBJECT, ResultCode.OTHER }, |
| | | { ResultCode.ENTRY_ALREADY_EXISTS, ResultCode.OTHER }, |
| | | { ResultCode.NOT_ALLOWED_ON_RDN, ResultCode.OTHER }, |
| | | { ResultCode.NOT_ALLOWED_ON_NONLEAF, ResultCode.OTHER }, |
| | | { ResultCode.UNWILLING_TO_PERFORM, ResultCode.OTHER }, |
| | | { ResultCode.OBJECTCLASS_VIOLATION, ResultCode.OTHER }, |
| | | // a conflict code while a different conflict code is configured - the deployment |
| | | // this whole class is written about, meeting a conflict it did not configure |
| | | { ResultCode.NO_SUCH_OBJECT, ResultCode.UNWILLING_TO_PERFORM }, |
| | | // and a failure which is not the configured code is not this server's either |
| | | { ResultCode.OTHER, ResultCode.NO_SUCH_OBJECT }, |
| | | { ResultCode.CONSTRAINT_VIOLATION, ResultCode.UNWILLING_TO_PERFORM }, |
| | | { ResultCode.INSUFFICIENT_ACCESS_RIGHTS, ResultCode.OTHER }, |
| | | { ResultCode.valueOf(9999), ResultCode.OTHER }, |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Sweeps every registered result code through the predicate against the list above. The |
| | | * named tables below say what the rule is on the codes which matter; this says the set |
| | | * behind it holds those and nothing else, so that a code put into |
| | | * {@code CONFLICT_RESULT_CODES} - which stops a storage failure carrying it from being |
| | | * retried - fails here as loudly as a code taken out of it. |
| | | * <p> |
| | | * It speaks for the predicate, not for what the replay hands it: {@code SUCCESS} and |
| | | * {@code NO_OPERATION} never reach the method, and what it answers for them is what the |
| | | * rest of this sweep is written from rather than a rule of its own. |
| | | * <p> |
| | | * Soft assertions so that a drift in both directions at once - a code added and another |
| | | * dropped - is reported in one run rather than one code per run. |
| | | */ |
| | | @Test |
| | | public void everyRegisteredCodeIsAServerFailureExactlyWhenConflictResolutionDoesNotOwnIt() |
| | | { |
| | | final SoftAssertions softly = new SoftAssertions(); |
| | | for (ResultCode code : configurableCodes()) |
| | | { |
| | | softly.assertThat(isServerFailure(code, code)) |
| | | .as("%s (%d) set as server-error-result-code is a failure of this server unless " |
| | | + "conflict resolution is the only thing which can solve it", |
| | | code, code.intValue()) |
| | | .isEqualTo(!CONFLICT_CODES.contains(code)); |
| | | } |
| | | softly.assertAll(); |
| | | } |
| | | |
| | | @Test(dataProvider = "conflictResultCodes") |
| | | public void conflictCodeConfiguredAsTheServerErrorCodeIsLeftToConflictResolution(ResultCode code) |
| | | { |
| | | assertThat(isServerFailure(code, code)) |
| | | .as("%s (%d) set as server-error-result-code must not take a change away from " |
| | | + "solveNamingConflict(), which is the only thing which can solve it", |
| | | code, code.intValue()) |
| | | .isFalse(); |
| | | } |
| | | |
| | | @Test(dataProvider = "codesOutsideTheConflictSet") |
| | | public void codeOutsideTheConflictSetConfiguredAsTheServerErrorCodeIsAServerFailure(ResultCode code) |
| | | { |
| | | assertThat(isServerFailure(code, code)) |
| | | .as("%s (%d) set as server-error-result-code is this server reporting an internal " |
| | | + "error, and conflict resolution can not solve it", |
| | | code, code.intValue()) |
| | | .isTrue(); |
| | | } |
| | | |
| | | /** |
| | | * "Whatever is configured" is every code which can be configured, rather than a handful of |
| | | * them: the predicate reads the configured code only after {@code UNAVAILABLE} has not |
| | | * matched, so a change which stops that short circuit for some code has to be looked for |
| | | * across all of them. |
| | | */ |
| | | @Test |
| | | public void unavailableIsAServerFailureWhateverIsConfigured() |
| | | { |
| | | final SoftAssertions softly = new SoftAssertions(); |
| | | for (ResultCode serverErrorResultCode : configurableCodes()) |
| | | { |
| | | softly.assertThat(isServerFailure(ResultCode.UNAVAILABLE, serverErrorResultCode)) |
| | | .as("the backend being offline or rebuilt is a failure of the server while " |
| | | + "server-error-result-code is %s (%d) just as much as it is by default", |
| | | serverErrorResultCode, serverErrorResultCode.intValue()) |
| | | .isTrue(); |
| | | } |
| | | softly.assertAll(); |
| | | } |
| | | |
| | | @Test(dataProvider = "resultAndAnotherConfiguredCode") |
| | | public void codeWhichIsNotTheConfiguredOneIsNotAServerFailure( |
| | | ResultCode result, ResultCode serverErrorResultCode) |
| | | { |
| | | assertThat(isServerFailure(result, serverErrorResultCode)) |
| | | .as("%s (%d) is not what this server puts on an internal error - it puts %s (%d) - " |
| | | + "so it is the operation which failed rather than the server", |
| | | result, result.intValue(), serverErrorResultCode, serverErrorResultCode.intValue()) |
| | | .isFalse(); |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.server.replication.plugin; |
| | | |
| | | import static org.mockito.Matchers.any; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | import static org.testng.Assert.*; |
| | | |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.opends.server.DirectoryServerTestCase; |
| | | 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; |
| | | import org.testng.annotations.Test; |
| | | |
| | | /** |
| | | * Tests the bookkeeping a replica does on its own changes: they are published in the order of |
| | | * their CSNs, and the announcement that the replica goes offline is only reported as sent when |
| | | * it really was. |
| | | * <p> |
| | | * These tests need no server: the changes are built by a CSNGenerator, which reads the time |
| | | * service, and the time service is up as soon as its class is loaded. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | @Test(groups = { "precommit", "replication" }, sequential = true) |
| | | public class PendingChangesTest extends DirectoryServerTestCase |
| | | { |
| | | private static final int SERVER_ID = 42; |
| | | |
| | | @Test |
| | | public void replicaOfflineMsgIsSentWhenNoChangeIsPending() throws Exception |
| | | { |
| | | final ReplicationDomain domain = mock(ReplicationDomain.class); |
| | | final PendingChanges pendingChanges = newPendingChanges(domain); |
| | | |
| | | final CSN offlineCSN = pendingChanges.putReplicaOfflineMsg(); |
| | | |
| | | assertNotNull(offlineCSN, "the message was published and must be reported as sent"); |
| | | final UpdateMsg published = onlyMsgPublishedBy(domain); |
| | | assertTrue(published instanceof ReplicaOfflineMsg, "published " + published); |
| | | assertEquals(published.getCSN(), offlineCSN); |
| | | } |
| | | |
| | | /** |
| | | * The message carries the newest CSN of the replica, so a change which is still in flight |
| | | * holds it back - and what was never published must not be reported as sent: the shutdown of |
| | | * a collocated replication server waits out the whole grace period of a message it was told |
| | | * about and which never reaches the wire. |
| | | */ |
| | | @Test |
| | | public void replicaOfflineMsgQueuedBehindAnUncommittedChangeIsNotReportedAsSent() throws Exception |
| | | { |
| | | final ReplicationDomain domain = mock(ReplicationDomain.class); |
| | | final PendingChanges pendingChanges = newPendingChanges(domain); |
| | | pendingChanges.putLocalOperation(newLocalOperation()); |
| | | |
| | | assertNull(pendingChanges.putReplicaOfflineMsg(), "nothing was published"); |
| | | |
| | | verify(domain, never()).publish(any(UpdateMsg.class)); |
| | | } |
| | | |
| | | /** |
| | | * A message which could not be published is given up on rather than left queued: the replica |
| | | * which could not announce itself offline is either shutting down, and the message dies with |
| | | * the process, or it is being disabled for an import or a configuration change - and once it |
| | | * comes back, announcing it offline on the session which follows would be a lie. |
| | | */ |
| | | @Test |
| | | public void replicaOfflineMsgWhichCouldNotBeSentIsNotPublishedLater() throws Exception |
| | | { |
| | | final ReplicationDomain domain = mock(ReplicationDomain.class); |
| | | final PendingChanges pendingChanges = newPendingChanges(domain); |
| | | final CSN changeCSN = pendingChanges.putLocalOperation(newLocalOperation()); |
| | | assertNull(pendingChanges.putReplicaOfflineMsg(), "nothing was published"); |
| | | |
| | | // The change which held the message back completes. |
| | | pendingChanges.commitAndPushCommittedChanges(changeCSN, mock(LDAPUpdateMsg.class)); |
| | | |
| | | final UpdateMsg published = onlyMsgPublishedBy(domain); |
| | | assertTrue(published instanceof LDAPUpdateMsg, "published " + published); |
| | | } |
| | | |
| | | private PendingChanges newPendingChanges(ReplicationDomain domain) |
| | | { |
| | | return new PendingChanges(new CSNGenerator(SERVER_ID, 0), domain); |
| | | } |
| | | |
| | | /** A local operation, i.e. one this replica must publish to the other replicas. */ |
| | | private PluginOperation newLocalOperation() |
| | | { |
| | | final PluginOperation operation = mock(PluginOperation.class); |
| | | when(operation.isSynchronizationOperation()).thenReturn(false); |
| | | return operation; |
| | | } |
| | | |
| | | /** |
| | | * The single message the domain was asked to publish, failing the test if it published |
| | | * anything else: a message which is not sent and a message which is sent twice are both the |
| | | * kind of mistake these tests are about. |
| | | */ |
| | | private UpdateMsg onlyMsgPublishedBy(ReplicationDomain domain) |
| | | { |
| | | final ArgumentCaptor<UpdateMsg> published = ArgumentCaptor.forClass(UpdateMsg.class); |
| | | verify(domain).publish(published.capture()); |
| | | return published.getValue(); |
| | | } |
| | | } |