This is an automated email from the ASF dual-hosted git repository. rmaucher pushed a commit to branch 11.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit fe26aeae113d42c380fed8571458891455e653e3 Author: opencode <[email protected]> AuthorDate: Wed Nov 4 13:29:42 2026 +0100 Make the slow query report's JMX registration hold together The reporting interceptor publishes one MBean per pool name, lazily on the first borrow, and releases it when the last live pool of the name closes. Publication and release are separate unguarded steps over a per-name registry, and this closes the ways that pairing came apart. A publish that lands behind the release of its own slot is an orphan nobody else knows of, and the registration is the only object that can still tell it apart from a live one; it now undoes such a publication - and does so about the ObjectName it occupies rather than the pool-name slot, because definitions of one name may compute different names, and an undo that read the slot left the bean standing under a name no release could ever reach. A release that cannot derive the name its registration occupies leaves that registration in place, so a later release can still take it down. Notifications are sent under the name the registration was published under rather than one recomputed from the emitting instance's properties, so the instances that never published - a two-thresholds chain, a pool that adopted a name another definition published - do not stamp them with a phantom source. A name that is already registered is noticed before the name is parsed and a publication built only to lose the race. A released name stays released: a notification still in flight while the last pool came down no longer puts the name back in the static registry, along with the listeners and the class loaders its broadcaster carries. A pool that carries no name cost the application its borrows, and a definition the pool took on after its start never met the start-time refusal meant to catch that pairing; it now loses its monitoring and nothing else, as every other unformable name does, and a value-less objectName property reads as absent for the same reason. Two neighbours carry their own change here. JmxUtil.unregisterJmx now answers whether the unregistration actually happened, with a new message for the case where the MBean is still registered afterwards - the module's callers run terminal lifecycles and still ignore the answer, but a refusal is no longer silent. ConnectionPool.close now also informs the definitions a connection chain was actually built from: a definition the mutable jdbcInterceptors property gained after the start and lost again before the close was named by neither of the sets the close worked through, yet its chains had registered state under the pool's name, so for a pool whose only reporting definition took that path the name's MBean outlived the pool by the life of the JVM. What is said about the interceptor now matches what it does: the descriptor carries the notification types the notifications have on the wire, with a test keeping the two from drifting; the module no longer claims the class needs Tomcat's JMX engine, which its own tests disprove by registering it in a plain JVM; the class javadoc opens with the notifyPool=false precondition the rest of its description silently assumes, and the branch implementing that precondition is no longer an empty if; the notification type constants are the pool MBean interface's own constants rather than copies of their text; and what a persistently refused registration costs per borrow is documented, with the reason retrying every borrow is worth paying it. Pinned by aRegistrationCompletedBehindItsOwnReleaseLeavesNothingPublished, aRegistrationCompletedBehindAReleaseAndATakeoverLeavesTheSuccessorAlone, aReleaseThatCannotDeriveTheNameKeepsTheRegistrationReleasable, aMalformedObjectNameOverrideFailsOnlyTheRegistration, everyDefinitionOfANameSendsTheNameTheRegistrationWasPublishedUnder, aNullObjectNamePropertyValueBehavesAsIfAbsent, theDefinitionThatBuiltALiveChainIsToldAboutTheCloseEvenIfThePropertyLostItAgain, aDefinitionAddedAfterTheStartAndRemovedBeforeTheCloseStillReleasesThePublishedMBean, aRegisteredNameCostsNoFurtherNameFormationPerBorrow, aLatePublishUnderANameTheRegistryNoLongerRecordsUndoesItself, aNotificationSentAfterTheNameWasReleasedDoesNotBringTheNameBack, aJmxReportingDefinitionAddedToANamelessPoolAfterTheStartStillServesItsBorrows, aSlowQueryOfANamelessPoolWithALateReportingDefinitionIsServedAndStillNotified, aSuccessfulUnregistrationComesBackAsReleased, anUnregistrationOfNothingCountsAsDone, aRefusedUnregistrationComesBackAsNotReleased, mBeanInfoAdvertisesTheNotificationTypesTheMBeanSends and theDescriptorAdvertisesTheWireTypesTheNotificationsCarry. --- modules/jdbc-pool/doc/jdbc-pool.xml | 10 +- .../apache/tomcat/jdbc/pool/ConnectionPool.java | 56 ++- .../jdbc/pool/interceptor/SlowQueryReportJmx.java | 338 ++++++++++++-- .../jdbc/pool/interceptor/mbeans-descriptors.xml | 15 +- .../org/apache/tomcat/jdbc/pool/jmx/JmxUtil.java | 26 +- .../tomcat/jdbc/pool/util/LocalStrings.properties | 1 + .../TestInterceptorRemovalBeforePoolClose.java | 63 +++ .../interceptor/TestSlowQueryJmxMBeanSurface.java | 65 +++ .../interceptor/TestSlowQueryJmxNotifications.java | 500 ++++++++++++++++++++- .../TestSlowQueryReportNamelessPool.java | 117 ++++- .../tomcat/jdbc/pool/jmx/TestJmxRegistration.java | 103 ++++- 11 files changed, 1216 insertions(+), 78 deletions(-) diff --git a/modules/jdbc-pool/doc/jdbc-pool.xml b/modules/jdbc-pool/doc/jdbc-pool.xml index 874824a5f8..ebf7db0242 100644 --- a/modules/jdbc-pool/doc/jdbc-pool.xml +++ b/modules/jdbc-pool/doc/jdbc-pool.xml @@ -889,13 +889,15 @@ <subsection name="org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReportJmx"> <p>Extends the <code>SlowQueryReport</code> and in addition to log entries it issues JMX notification for monitoring tools to react to. Inherits all the attributes from its parent class. - This class uses Tomcat's JMX engine so it won't work outside of the Tomcat container. - By default, JMX notifications are sent through the ConnectionPool mbean if it is enabled. - The <code>SlowQueryReportJmx</code> can also register an MBean if <code>notifyPool=false</code> + This class registers with the JVM's platform MBeanServer, so it works in any JVM, inside or + outside a Tomcat container. By default, JMX notifications are sent through the ConnectionPool + mbean if it is enabled; with <code>notifyPool=false</code> the <code>SlowQueryReportJmx</code> + instead publishes an MBean of its own, one per pool name, and sends the notifications through it. </p> <attributes> <attribute name="notifyPool" required="false"> - <p>(boolean as String) Set to false if you want JMX notifications to go to the <code>SlowQueryReportJmx</code> MBean + <p>(boolean as String) Set to false if you want JMX notifications to go to the <code>SlowQueryReportJmx</code> + MBean rather than to the pool's own MBean. Doing so also publishes that interceptor's MBean, described above. The default value is <code>true</code>. </p> </attribute> diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java index 1888916a3f..f82e013215 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/ConnectionPool.java @@ -37,6 +37,7 @@ import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -158,6 +159,20 @@ public class ConnectionPool { */ private volatile PoolProperties.InterceptorDefinition[] startedInterceptors; + /** + * The interceptor definitions a connection chain of this pool was actually built from. The close pairs with + * {@link #startedInterceptors} and with the definitions the {@code jdbcInterceptors} property lists at close + * time, but neither of those covers a definition the property gained after the start and lost again before the + * close: it never saw the start to be recorded there, and by the close it is gone from the list the close reads + * - yet a chain was built from it, and what a borrow through that chain registered under the pool's name (the + * statistics and, for the reporting interceptors, the published MBean) is only ever taken down by a close. + * Recording the definitions at chain construction is what lets the close find that third set. Entries are the + * small configuration objects the property parses, one per definition per reconfiguration of a live pool; a + * restarted pool is a new instance pairing over its own generation. + */ + private final Set<PoolProperties.InterceptorDefinition> chainDefinitions = + ConcurrentHashMap.newKeySet(); + /** * Since newProxyInstance performs the same operation, over and over again, it is much more optimized if we simply * store the constructor ourselves. One constructor is cached per proxy kind (with and without the XAConnection @@ -472,6 +487,10 @@ public class ConnectionPool { // Set up the interceptor chain PoolProperties.InterceptorDefinition[] proxies = getPoolProperties().getJdbcInterceptorsAsArray(); for (int i = proxies.length - 1; i >= 0; i--) { + // Record what this chain was built from: the definitions the + // close has to pair with are the ones a chain ran, not only + // the ones the property lists when the pool comes down + chainDefinitions.add(proxies[i]); try { // Create a new instance JdbcInterceptor interceptor = proxies[i].getInterceptorClass().getConstructor().newInstance(); @@ -773,8 +792,13 @@ public class ConnectionPool { // gained since the start are told as well, so a definition that never // saw a start still takes down - best effort, on a fresh instance that // shares no state with the chains that served the borrows - whatever a - // borrow through it registered under the pool's name. A definition the - // start told is not told twice over its own unchanged configuration. + // borrow through it registered under the pool's name. The third set the + // close has to reach are the definitions those chains were actually + // built from: a definition the property gained after the start and lost + // again before the close is named by neither of the lists above, yet it + // armed the connections that registered state under the pool's name. + // A definition the close already told over its start or its current + // configuration is not told twice. PoolProperties.InterceptorDefinition[] started = startedInterceptors; if (started != null) { for (int i = 0; i < started.length; i++) { @@ -783,10 +807,15 @@ public class ConnectionPool { } PoolProperties.InterceptorDefinition[] proxies = getPoolProperties().getJdbcInterceptorsAsArray(); for (int i = 0; i < proxies.length; i++) { - if (!isToldAboutTheStart(proxies[i], started)) { + if (!isToldAlready(proxies[i], started)) { informInterceptorOfPoolClosure(proxies[i]); } } + for (PoolProperties.InterceptorDefinition chainDefinition : chainDefinitions) { + if (!isToldAlready(chainDefinition, started) && !isToldAlready(chainDefinition, proxies)) { + informInterceptorOfPoolClosure(chainDefinition); + } + } } @@ -813,22 +842,23 @@ public class ConnectionPool { /** - * Whether the given definition is one the pool told about its start: the very definition object the start - * read, or a re-parsed one configured the same way. The parsed list is cached and only re-parsed when the - * property is rewritten, so an unchanged list answers by object identity alone. + * Whether the close of the given definition is already covered by a set of definitions the close has told (or + * is telling): the very definition object in that set, or one configured the same way. The parsed list is cached + * and only re-parsed when the property is rewritten, so an unchanged list answers by object identity alone. * * @param definition the definition to check - * @param started the definitions the start told, or {@code null} when the pool never told any + * @param told the definitions already covered by one set of the close's notifications, or {@code null} + * when that set does not exist - a pool whose start told nothing * - * @return {@code true} if the close of this definition is already covered by the start-time set + * @return {@code true} if the close of this definition is already covered by the given set */ - private static boolean isToldAboutTheStart(PoolProperties.InterceptorDefinition definition, - PoolProperties.InterceptorDefinition[] started) { - if (started == null) { + private static boolean isToldAlready(PoolProperties.InterceptorDefinition definition, + PoolProperties.InterceptorDefinition[] told) { + if (told == null) { return false; } - for (int i = 0; i < started.length; i++) { - if (started[i] == definition || isConfiguredTheSame(started[i], definition)) { + for (int i = 0; i < told.length; i++) { + if (told[i] == definition || isConfiguredTheSame(told[i], definition)) { return true; } } diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/SlowQueryReportJmx.java b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/SlowQueryReportJmx.java index b4d98b1bc1..57fca1ade4 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/SlowQueryReportJmx.java +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/SlowQueryReportJmx.java @@ -26,6 +26,8 @@ import java.util.concurrent.atomic.AtomicLong; import javax.management.InstanceAlreadyExistsException; import javax.management.ListenerNotFoundException; import javax.management.MBeanNotificationInfo; +import javax.management.MBeanRegistration; +import javax.management.MBeanServer; import javax.management.MalformedObjectNameException; import javax.management.Notification; import javax.management.NotificationBroadcasterSupport; @@ -44,6 +46,7 @@ import org.apache.juli.logging.LogFactory; import org.apache.tomcat.jdbc.pool.ConnectionPool; import org.apache.tomcat.jdbc.pool.PoolProperties.InterceptorProperty; import org.apache.tomcat.jdbc.pool.PooledConnection; +import org.apache.tomcat.jdbc.pool.jmx.ConnectionPoolMBean; import org.apache.tomcat.jdbc.pool.jmx.JmxUtil; import org.apache.tomcat.jdbc.pool.util.MessageFactory; @@ -51,6 +54,14 @@ import org.apache.tomcat.jdbc.pool.util.MessageFactory; * JMX-enabled slow query report interceptor. Publishes slow and failed query statistics to JMX and provides * notifications when slow or failed queries are detected. * <p> + * All of the publication described below belongs to the interceptor's own MBean, which exists only when the + * interceptor is configured with {@code notifyPool=false}. In the default {@code notifyPool=true} mode this class + * registers nothing of its own: the notifications are raised on the pool's own MBean instead, through + * {@link org.apache.tomcat.jdbc.pool.jmx.ConnectionPool#notify(String, String)}, and that MBean is registered by the + * pool itself when its {@code jmxEnabled} property is on - so a pool that is neither JMX-enabled nor configured with + * {@code notifyPool=false} reports its slow and failed queries to its log alone. + * </p> + * <p> * The MBean and the notification broadcaster are published for the pool <em>name</em>: the first interceptor instance * to borrow a connection registers them, and every other instance sharing the name - the other connections of that * pool, the second interceptor of the recommended two-thresholds configuration, or a second pool configured with the @@ -74,13 +85,28 @@ import org.apache.tomcat.jdbc.pool.util.MessageFactory; */ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationEmitter, SlowQueryReportJmxMBean { /** - * Notification type for slow query events. + * Notification type for slow query events. The pool's own MBean reports the same event under the same type when + * this interceptor is configured with {@code notifyPool=true}, so this is that very type and not a copy of its + * text: a client filters notifications on the type, and the two channels have to answer to one string. */ - public static final String SLOW_QUERY_NOTIFICATION = "SLOW QUERY"; + public static final String SLOW_QUERY_NOTIFICATION = ConnectionPoolMBean.SLOW_QUERY_NOTIFICATION; /** - * Notification type for failed query events. + * Notification type for failed query events, shared with the pool's own MBean for the same reason as + * {@link #SLOW_QUERY_NOTIFICATION}. */ - public static final String FAILED_QUERY_NOTIFICATION = "FAILED QUERY"; + public static final String FAILED_QUERY_NOTIFICATION = ConnectionPoolMBean.FAILED_QUERY_NOTIFICATION; + + /** + * The notification information every broadcaster of this class is built with, so that both the published MBean and + * the interceptor's own emitter surface advertise the two types above. JMX clients discover what an MBean can send + * through {@code MBeanInfo.getNotifications()} - consoles, monitoring and bridge agents, tooling that builds a + * notification filter from the advertised types - and a broadcaster constructed without this information leaves the + * MBean advertising none of the types it actually delivers. + */ + private static final MBeanNotificationInfo[] NOTIFICATION_INFO = { new MBeanNotificationInfo( + new String[] { SLOW_QUERY_NOTIFICATION, FAILED_QUERY_NOTIFICATION }, + Notification.class.getName(), + "A slow or failed query was detected by the slow query report.") }; /** * Property key for overriding the JMX object name. @@ -132,10 +158,12 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE protected static final ConcurrentHashMap<String, ObjectName> registrationFailuresLogged = new ConcurrentHashMap<>(); /** - * Notification broadcaster used before the pool name is known; once the pool name is known, {@link #getNotifier()} - * returns the broadcaster shared by all interceptor instances of that pool. + * Notification broadcaster used before the pool name is known, and the one a notification of a name that has been + * released is sent through; once the pool name is known and live, {@link #getNotifier()} returns the broadcaster + * shared by all interceptor instances of that pool. Like the shared broadcasters, it is built with + * {@link #NOTIFICATION_INFO}, so every handle's emitter surface advertises the types it sends. */ - protected volatile NotificationBroadcasterSupport notifier = new NotificationBroadcasterSupport(); + protected volatile NotificationBroadcasterSupport notifier = new NotificationBroadcasterSupport(NOTIFICATION_INFO); /** * Returns the notification broadcaster shared by all registrants of the given pool name. @@ -145,7 +173,7 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE * @return the shared broadcaster registered for that name */ private static NotificationBroadcasterSupport broadcasterFor(String name) { - return notifiers.computeIfAbsent(name, key -> new NotificationBroadcasterSupport()); + return notifiers.computeIfAbsent(name, key -> new NotificationBroadcasterSupport(NOTIFICATION_INFO)); } /** @@ -162,6 +190,35 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE return broadcasterFor(name); } + /** + * Returns the broadcaster that the notifications of this interceptor's pool are sent through. Unlike + * {@link #getNotifier()} this one reads rather than provides: a send is not a reason for a name to exist. The + * name's broadcaster is made for a live pool - including one whose registration was refused, which reports + * through it all the same - but a name that no live pool holds any more has been released, and an entry put back + * under it by a notification that was simply still in flight when the last pool came down is taken down by + * nothing ever again: not the close that already ran, and not a later one, because no later pool of that name + * exists. What such a send can still serve is whoever reached this instance, so it falls back to the + * instance's own broadcaster. + * + * @return the broadcaster to send a notification of this interceptor's pool through + */ + private NotificationBroadcasterSupport broadcasterToSendTo() { + String name = poolName; + if (name == null) { + return notifier; + } + NotificationBroadcasterSupport shared = notifiers.get(name); + if (shared != null) { + return shared; + } + if (mbeans.containsKey(name) || getPoolStats(name) != null) { + // A pool of the name is live: its broadcaster is built by the first + // thing that needs it, and a send of a live pool is one + return broadcasterFor(name); + } + return notifier; + } + /** * Adds a notification listener. * @@ -412,7 +469,7 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE jmx.notify(type, query); } } else { - NotificationBroadcasterSupport target = getNotifier(); + NotificationBroadcasterSupport target = broadcasterToSendTo(); if (target != null) { Notification notification = new Notification(type, getNotificationSource(), sequence, System.currentTimeMillis(), query); @@ -435,24 +492,44 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE * serializable. This MBean is not, and neither is the pool it reports on, so the source is the {@link ObjectName} * the MBean is registered under, which names the pool and is serializable. Interceptor instances are created per * pooled connection and none of them is the published resource - that is the pool name's {@link Registration} - so - * an instance cannot name itself: every instance of a pool name sends the very name its registration was published - * under. An instance whose name cannot be formed - a malformed {@code objectName} property or a pool name that - * cannot be quoted into an ObjectName - was never registered at all, so no listener reached through a connector can - * exist for it; the source is then a string of the default name's shape, which still names the pool for the local - * listeners the broadcaster serves. + * an instance cannot name itself: the source is the name the registry's registration records as published, which + * every instance of the name sends alike. Instances of a second definition under the same name - a two-thresholds + * chain, or a pool that adopted a name another definition published - never published anything themselves and must + * not stamp the notifications with the name their own properties would compute: that name is registered under + * nothing, and a client filtering or grouping by source reads the published one. An instance whose name cannot be + * formed - a malformed {@code objectName} property or a pool name that cannot be quoted into an ObjectName - was + * never registered at all, so no listener reached through a connector can exist for it; the source is then a string + * of the default name's shape, which still names the pool for the local listeners the broadcaster serves. A pool + * that carries no name at all is the same case taken to its end: it holds no registration to name itself by, and + * the shape its source takes is the default one with no name in it - whatever survives delivery, it cannot name a + * pool that never had a name to register. * - * @return the ObjectName the registration of this interceptor's pool name is published under, or, when that name - * cannot be formed, a string of the default name's shape + * @return the ObjectName the registration of this interceptor's pool name is published under, or, when nothing is + * registered under the name or the published name cannot be told, a name formed from this instance - + * as an ObjectName where that succeeds, and as a string of the default name's shape where it does not */ protected Object getNotificationSource() { + String name = poolName; try { - return getObjectName(getClass(), poolName); - } catch (MalformedObjectNameException x) { + // A pool that carries no name holds no registration to name itself + // by: nothing can be keyed under no name (see registerJmx), and the + // registry would refuse the lookup before answering it + SlowQueryReportJmxMBean registered = name == null ? null : mbeans.get(name); + if (registered != null) { + // The name as that registration itself recorded it: the same + // derivation the release path uses to unregister exactly the + // occupied name, for the same reason - a name recomputed from + // the properties of the instance that happens to be sending + // can differ from the one the MBeanServer actually occupies + return registrationObjectName(registered); + } + return getObjectName(getClass(), name); + } catch (MalformedObjectNameException | RuntimeOperationsException x) { // The name construction getObjectName uses when no override is // configured - with a malformed name this MBean was never // registered, so this fallback is only read by local listeners, // for which it must still name the pool - return ConnectionPool.POOL_JMX_TYPE_PREFIX + getClass().getName() + ",name=" + poolName; + return ConnectionPool.POOL_JMX_TYPE_PREFIX + getClass().getName() + ",name=" + name; } } @@ -623,24 +700,41 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE * The broadcaster and the registration-failure marker belong to the name as much as the MBean does, so the release * drops them even when this pool never held the registry slot: a pool whose every registration attempt was refused * still reported through the broadcaster, and its throttle marker must not outlive the name and hush the first - * failure of a later failure cycle under the name. Both entries are created lazily - by {@link #getNotifier()} and - * by listeners a client adds through interceptor handles it still holds - so a client subscribing to the released - * name concurrently with this release can have its late subscription re-insert an entry nothing registered under - * the name uses any more; a fresh registration under the name re-uses or replaces it. + * failure of a later failure cycle under the name. Both entries are created lazily - by a report of a name that + * is still live and by listeners a client adds through interceptor handles it still holds - so a client + * subscribing to the released name concurrently with this release can have its late subscription re-insert an + * entry nothing registered under the name uses any more; a fresh registration under the name re-uses or replaces + * it. A notification that is merely still in flight while this release runs is not given that power, as + * broadcasterToSendTo() reads the name's broadcaster instead of providing one: an intent to listen is a reason to + * give the name a broadcaster again, a send that lost the race to the close is not. + * </p> + * <p> + * The ObjectName to unregister is derived while the registry still holds the slot, before the slot is dropped: the + * derivation can fail for a registration this class did not publish itself (see {@link + * #registrationObjectName(SlowQueryReportJmxMBean)}), and a release that cannot tell the MBeanServer which name to + * take down must leave the slot held so that a later release still can. Dropping the slot on the way to a failed + * derivation would strand the published MBean behind a registry that no longer records it - the one half-released + * state no later release of the name can repair. * </p> */ protected void deregisterJmx() { + SlowQueryReportJmxMBean registered = mbeans.get(poolName); try { - SlowQueryReportJmxMBean registered = mbeans.get(poolName); - if (registered != null && mbeans.remove(poolName, registered)) { - JmxUtil.unregisterJmx(registrationObjectName(registered)); + if (registered != null) { + // Derivation first, while the slot is still held: if it + // throws, the registry still records the published MBean and + // a later release of the name can still take it down + ObjectName name = registrationObjectName(registered); + if (mbeans.remove(poolName, registered)) { + JmxUtil.unregisterJmx(name); + } } - notifiers.remove(poolName); - registrationFailuresLogged.remove(poolName); } catch (MalformedObjectNameException | RuntimeOperationsException e) { log.warn(MessageFactory.get("slowQueryReportJmx.jmxDeregistrationFailed"), e); + } finally { + notifiers.remove(poolName); + registrationFailuresLogged.remove(poolName); } - } /** @@ -680,6 +774,14 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE /** * Returns the JMX ObjectName for the given class and pool name. + * <p> + * A property carrying no value - which the interceptor-string grammar + * cannot produce, but a programmatically built {@link + * org.apache.tomcat.jdbc.pool.PoolProperties.InterceptorDefinition} can + * hand through - names nothing to override, and reads as if the property + * were absent, the way every sibling property of this family reads its + * null value. + * </p> * * @param clazz the interceptor class * @param poolName the name of the connection pool @@ -691,8 +793,9 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE public ObjectName getObjectName(Class<?> clazz, String poolName) throws MalformedObjectNameException { ObjectName oname; Map<String, InterceptorProperty> properties = getProperties(); - if (properties != null && properties.containsKey(objectNameAttribute)) { - oname = new ObjectName(properties.get(objectNameAttribute).getValue()); + InterceptorProperty override = properties == null ? null : properties.get(objectNameAttribute); + if (override != null && override.getValue() != null) { + oname = new ObjectName(override.getValue()); } else { oname = new ObjectName(ConnectionPool.POOL_JMX_TYPE_PREFIX + clazz.getName() + ",name=" + poolName); } @@ -709,16 +812,57 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE * and skip the registration, so the pool would claim an MBean for its whole life that was never published - and * would not re-register even after whatever made the MBeanServer refuse it is gone. Releasing the slot lets a later * borrow retry; {@code JmxUtil.registerJmx} cannot be used for the attempt because it swallows every exception and - * returns a name whatever happened, so the failure of its own call is unobservable to the caller. Only the first - * refusal of a name is logged with its exception, so a persistent refusal is not reported once per borrow. + * returns a name whatever happened, so the failure of its own call is unobservable to the caller. The retry is not + * made cheaper than the attempt it repeats, and that is deliberate: the first borrow that finds the name free + * publishes it, so a clash with a stale or foreign MBean costs monitoring only for as long as that MBean is there, + * while a backoff would make every pool that ever clashed wait out a delay before its monitoring came back. What a + * persistent refusal therefore costs is one publish attempt per borrow - the name formed, a {@link Registration} + * built, and the MBeanServer's refusal - on a pool whose monitoring is misconfigured, which is the cost paid for + * that recovery. Only the first + * refusal of a name is logged with its exception, so a persistent refusal is not reported once per borrow. A borrow + * that finds the slot held is skipped before the ObjectName is formed: forming it and building a {@link + * Registration} for that race can only ever lose the race, and the healthy path pays that price once per pool + * name rather than once per borrow. A pool that carries no name is skipped before any of it: no name is not a + * key the registry takes, and the start-time refusal of such a pool (see {@link SlowQueryReport#poolStarted}) + * does not reach a definition the mutable interceptor property hands over later, so this is where that pairing + * is met - and it costs the monitoring, never the borrow. + * The one outcome this method cannot observe - a publication that lands after a release already let go of the slot + * it took - is undone by the {@link Registration} itself, which checks its own publication as the server commits + * it (see {@link Registration#postRegister(Boolean)}). */ protected void registerJmx() { try { - // Only if we notify the pool itself + // With the pool notified, the notifications go to the pool's own + // MBean and there is nothing for this interceptor to register if (isNotifyPool()) { - - } else if (getCompositeType() != null) { - ObjectName oname = getObjectName(getClass(), poolName); + return; + } + if (getCompositeType() != null) { + String name = poolName; + if (name == null) { + // The pool carries no name, and no name is not a name: the + // registry, the failure throttle and the ObjectName are all + // keyed by it, and the map takes no null key. A definition + // the pool was started with refuses this pool at its start + // with a message naming the missing setting (see + // SlowQueryReport#poolStarted); a definition the mutable + // interceptor property hands over later never runs that + // start callback, so the borrow path is where the pairing + // shows up - and a borrow is not the place to decide it. + // Declining quietly costs the monitoring, which is all a + // name that cannot be had can ever cost. + return; + } + if (mbeans.containsKey(name)) { + // The name holds a registration - published, or being + // published by a concurrent borrow: this borrow cannot + // take the slot either way, so it forms no ObjectName and + // builds no Registration just to lose the race to its own + // name's registration. A refused publish frees the slot + // again, so a retry needs no fresh look past this read. + return; + } + ObjectName oname = getObjectName(getClass(), name); // The registry and the MBeanServer keep the registration // resource, never this instance: an instance reaches the pool // (its own and the inherited pool fields), so publishing one @@ -726,8 +870,8 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE // abandoned without closing - the only path that ever takes a // registration down again is a close, which abandonment // never runs - Registration registration = new Registration(poolName, oname); - if (mbeans.putIfAbsent(poolName, registration) == null) { + Registration registration = new Registration(name, oname); + if (mbeans.putIfAbsent(name, registration) == null) { try { // The registration carries the ObjectName it is to be // published under from its construction: a release @@ -737,10 +881,10 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE .registerMBean(registration, oname); // A registration under the name works again - drop // the marker so a later failure cycle reports loudly - registrationFailuresLogged.remove(poolName); + registrationFailuresLogged.remove(name); } catch (Exception x) { - mbeans.remove(poolName, registration); - if (oname.equals(registrationFailuresLogged.putIfAbsent(poolName, oname))) { + mbeans.remove(name, registration); + if (oname.equals(registrationFailuresLogged.putIfAbsent(name, oname))) { // The loud report went out with the first // refusal of this name; repeats ride along at // debug, without a stack trace, so a persistent @@ -752,7 +896,7 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE // First refusal for the name, or the name now // fails under a different ObjectName: report it // loudly and remember to throttle the repeats - registrationFailuresLogged.put(poolName, oname); + registrationFailuresLogged.put(name, oname); if (x instanceof InstanceAlreadyExistsException) { // Retryable: the name may be held by a stale // or foreign MBean that a later borrow finds @@ -769,6 +913,17 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE SlowQueryReport.class.getName())); } } catch (MalformedObjectNameException | RuntimeOperationsException e) { + // A name that cannot be formed - a malformed objectName property, + // or a pool name carrying characters the ObjectName grammar + // reserves - fails here, before the registry slot and the + // registrationFailuresLogged throttle are reachable: there is no + // ObjectName to record against the name, so the per-name throttle + // cannot key this failure. The error is therefore reported on + // every borrow of such a pool. That is acceptable: the condition + // is a permanent misconfiguration, the stack trace is the signal + // that makes the operator fix it, and a busy pool with a + // misnamed monitoring MBean is rarer - and less costly - than a + // suppressed error nobody ever acts on. log.error(MessageFactory.get("slowQueryReportJmx.jmxRegistrationFailedNoData"), e); } } @@ -807,8 +962,22 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE * The class is public because the Standard-MBean introspection publishes the resource through its interfaces; * instances are created only by {@link #registerJmx()}. * </p> + * <p> + * A registration is valid only while the {@link #mbeans} slot of its name holds it. The publication and the + * release of a name are separate, unguarded steps, so a publication can land behind a release of its own slot: + * the release takes the slot out and unregisters a name nothing was published under yet, and the publication then + * completes with nothing left in the JVM that still knows of it. The registration is the one object that can tell + * its own publication apart from a name already let go of - {@link #postRegister(Boolean)} is the server + * announcing that its repository has taken the registration, and a registration that finds its slot gone by then + * takes that late publication back down itself, leaving the name free for the next pool to register it cleanly. + * A slot by then held by another registration is not gone, but it only settles the question for the name that + * registration occupies: a takeover published under this very name can only have got there after the release took + * this publication down, so there is nothing of this one left to undo and the takeover is left alone, while a + * takeover published under another name leaves this publication standing under a name the registry no longer + * records - which nothing but the registration itself can still give up. + * </p> */ - public static class Registration implements NotificationEmitter, RegistrationMBean { + public static class Registration implements NotificationEmitter, RegistrationMBean, MBeanRegistration { /** * The pool name this registration reports for. */ @@ -832,6 +1001,87 @@ public class SlowQueryReportJmx extends SlowQueryReport implements NotificationE this.publishedUnder = publishedUnder; } + /* + * The registration is published under exactly the ObjectName it was + * constructed with; that name is also what a release unregisters, so + * the server is answered with the name it asked about, unchanged. + */ + @Override + public ObjectName preRegister(MBeanServer server, ObjectName name) throws Exception { + return name; + } + + /* + * A registration is published under its ObjectName and held by the + * per-name slot, and those are two different questions: two + * definitions of one pool name may compute different ObjectNames (see + * registrationObjectName), so what the slot answers is not what decides + * whether unregistering this name destroys somebody else's live MBean. + * postRegister is the MBeanServer announcing that its repository has + * taken this registration, the earliest point at which the outcome of + * the publication is known here. A publication whose slot is gone was + * decided away by a release that ran while it was in flight - the + * release took the slot out and unregistered a name nothing had been + * published under yet, so nothing else in the JVM knows to take this + * now-late publication down. A publication whose slot is held by + * another registration is only accounted for when that registration + * occupies this very name: it can have committed there only after this + * publication was already given up, which is why unregistering now + * would take down the live MBean of the pool that took the name over, + * with the registry correctly claiming that one is published. Under a + * different name the successor's commit never needed this publication + * to be given up first, the registry records only the successor's name, + * and no release of the pool can ever reach this one - so the + * registration is the only object that can still undo it. A + * registration refused before its commit was never published and keeps + * its name - if any - with whoever else holds it, which is why only a + * completed registration here undoes anything. + */ + @Override + public void postRegister(Boolean registrationDone) { + if (Boolean.TRUE.equals(registrationDone) && publicationIsLeftWithoutOwner()) { + JmxUtil.unregisterJmx(publishedUnder); + } + } + + /* + * Whether this publication, at the moment its commit is announced, + * stands under a name nothing in this JVM can give up any more. + */ + private boolean publicationIsLeftWithoutOwner() { + SlowQueryReportJmxMBean held = mbeans.get(registrationPoolName); + if (held == null) { + // The release ran while this publication was in flight: it is + // an orphan, and the name it carries is free for the next pool + return true; + } + if (held == this) { + // The registry holds this publication: it is the name's own + return false; + } + if (held instanceof Registration) { + // A takeover - and the only question that matters is whether it + // occupies this name or came to the slot by another one + return !((Registration) held).publishedUnder.equals(publishedUnder); + } + // A value a subclass put into the registry itself: the name it + // occupies cannot be told from here, so it is given the field and + // this publication leaves the name alone + return false; + } + + @Override + public void preDeregister() throws Exception { + // nothing to guard: the registry bookkeeping runs around the + // unregistration in deregisterJmx(), not in the resource + } + + @Override + public void postDeregister() { + // nothing to release: the resource holds no state beyond the two + // fields a construction gave it + } + @Override public String getPoolName() { return registrationPoolName; diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/mbeans-descriptors.xml b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/mbeans-descriptors.xml index 52eed076ce..b50d78ab1c 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/mbeans-descriptors.xml +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/interceptor/mbeans-descriptors.xml @@ -20,19 +20,22 @@ "http://jakarta.apache.org/commons/dtds/mbeans-descriptors.dtd"> <mbeans-descriptors> - <mbean description="Reports " domain="tomcat.jdbc" group="jdbc-pool" name="SlowQueryReportJmx" + <mbean description="Reports slow and failed queries through JMX and publishes the statistics of every pool registered under its name" domain="tomcat.jdbc" group="jdbc-pool" name="SlowQueryReportJmx" type="org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReportJmx"> <attribute description="The name of the connection pool this Jmx bean is representing" name="PoolName" type="java.lang.String" writeable="false"/> <attribute description="List of all registered connections pools" name="PoolNames" type="[Ljava.lang.String;" writeable="false"/> - <attribute description="All the recorded query stats. " name="SlowQueriesCD" type="[Ljavax.management.openmbean.CompositeData;" writeable="false"/> + <attribute description="All the recorded query stats, as one CompositeData per recorded query" name="SlowQueriesCD" type="[Ljavax.management.openmbean.CompositeData;" writeable="false"/> - <notification description="Notification sent out by the slow query report when a query exceeds the threshold" name="slow-query"> - <notification-type>Slow query</notification-type> + <!-- The notification-type values are the wire types of the notifications the MBean + sends; they must stay equal to the SLOW_QUERY_NOTIFICATION and + FAILED_QUERY_NOTIFICATION constants, which is pinned by the surface test. --> + <notification description="Notification sent out by the slow query report when a query exceeds the threshold" name="SLOW QUERY"> + <notification-type>SLOW QUERY</notification-type> </notification> - <notification description="Notification sent out by the slow query report when a query fails execution" name="failed-query"> - <notification-type>Failed query execution</notification-type> + <notification description="Notification sent out by the slow query report when a query fails execution" name="FAILED QUERY"> + <notification-type>FAILED QUERY</notification-type> </notification> <operation description="Clears all the query stats" impact="ACTION" name="resetStats" returnType="void"/> diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/jmx/JmxUtil.java b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/jmx/JmxUtil.java index 145599ee7f..b3dcc4c7c4 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/jmx/JmxUtil.java +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/jmx/JmxUtil.java @@ -19,6 +19,7 @@ package org.apache.tomcat.jdbc.pool.jmx; import java.lang.management.ManagementFactory; +import javax.management.InstanceNotFoundException; import javax.management.MalformedObjectNameException; import javax.management.ObjectName; @@ -71,16 +72,33 @@ public class JmxUtil { /** * Unregister an MBean. * - * @param oname the object name to unregister + * @param oname the object name to unregister, or {@code null} to release + * nothing + * + * @return {@code true} if the name is no longer registered - either this + * call took it down, or it was already gone, or there was + * nothing to release; {@code false} if the MBean server refused + * to unregister it, which means the MBean is still published + * under that name and, since callers drop their handle + * regardless, held by nothing for the rest of the JVM's life */ - public static void unregisterJmx(ObjectName oname) { + public static boolean unregisterJmx(ObjectName oname) { if (oname == null) { - return; + return true; } try { ManagementFactory.getPlatformMBeanServer().unregisterMBean(oname); + return true; + } catch (InstanceNotFoundException e) { + // The MBean is already down - the end state this call was asked to + // bring about, not a failure, and no caller can act on it + log.debug(MessageFactory.get("jmxUtil.jmxUnregistrationFailed") + " " + oname); + return true; } catch (Exception e) { - log.error(MessageFactory.get("jmxUtil.jmxUnregistrationFailed"), e); + // The MBean is still registered, and this name is now owned by + // nobody: say so, with the name, so an operator can take it down + log.error(MessageFactory.get("jmxUtil.jmxUnregistrationRefused", oname), e); + return false; } } diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties index dc03354872..8c80cdd60c 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/util/LocalStrings.properties @@ -155,6 +155,7 @@ jmxConnectionPool.droppingNotification=Dropping notification of type [{0}] for p jmxUtil.jmxRegistrationFailed=Jmx registration failed. jmxUtil.jmxUnregistrationFailed=Jmx unregistration failed. +jmxUtil.jmxUnregistrationRefused=Jmx unregistration of [{0}] was refused. The MBean is still registered under that name, and its caller releases the name regardless, so nothing will ever unregister it again: it keeps a live management surface and pins the component behind it for the life of the JVM. poolProperties.invalidInterceptorProperty=Invalid interceptor property ''{0}'', missing ''='', in jdbcInterceptors value [{1}] poolProperties.credentialClearStripsStringEntry=Clearing the ''{0}'' credential also removed the entry the connectionProperties string contributes for that key. The string still names it, so every connection established from now on goes without that credential, and a connectionProperties reconfigure that re-names the key applies it again. Take the credential out of the connectionProperties string to retire it. diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestInterceptorRemovalBeforePoolClose.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestInterceptorRemovalBeforePoolClose.java index 5dd8c029b0..17e594ee33 100644 --- a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestInterceptorRemovalBeforePoolClose.java +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestInterceptorRemovalBeforePoolClose.java @@ -202,4 +202,67 @@ class TestInterceptorRemovalBeforePoolClose extends JdbcPoolBaseTest { ds.close(); } } + + @Test + void theDefinitionThatBuiltALiveChainIsToldAboutTheCloseEvenIfThePropertyLostItAgain() + throws Exception { + LifecycleRecordingInterceptor.resetCounts(); + DataSource ds = poolNamed("AddedThenRemoved-" + System.nanoTime(), ""); + try { + ds.createPool(); + ds.setJdbcInterceptors(RECORDING + "(marker=transient)"); + try (@SuppressWarnings("unused") Connection con = ds.getConnection()) { + // the chain this connection runs was built from the added + // definition - the tie that the started list and the current + // list both miss once the property drops it again + } + ds.setJdbcInterceptors(""); + ds.close(); + + Assertions.assertEquals(0, LifecycleRecordingInterceptor.starts("transient"), + "a definition the pool never started must not be credited with a start"); + Assertions.assertEquals(1, LifecycleRecordingInterceptor.closes("transient"), + "a definition the property gained after the start and lost again before the" + + " close must still be told about the close: it built the chain a" + + " borrow ran, and whatever that chain registered under the pool's" + + " name only a close takes down - the started list never held it and" + + " the current list no longer does"); + } finally { + ds.close(); + } + } + + @Test + void aDefinitionAddedAfterTheStartAndRemovedBeforeTheCloseStillReleasesThePublishedMBean() + throws Exception { + String poolName = "AddedThenRemovedJmx-" + System.nanoTime(); + DataSource ds = poolNamed(poolName, ""); + ObjectName oname = new ObjectName("tomcat.jdbc:type=" + SlowQueryReportJmx.class.getName() + + ",name=" + poolName); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + try { + ds.createPool(); + ds.setJdbcInterceptors(SlowQueryReportJmx.class.getName() + + "(notifyPool=false,threshold=0)"); + try (Connection con = ds.getConnection(); Statement st = con.createStatement()) { + st.execute("SELECT 1 FROM SYSTEM_RANGE(1,1)"); + } + Assertions.assertTrue(server.isRegistered(oname), + "the premise: a borrow through the added definition published the MBean" + + " for the pool name"); + + ds.setJdbcInterceptors(""); + ds.close(); + + Assertions.assertFalse(server.isRegistered(oname), + "the published MBean must not outlive the pool by the life of the JVM" + + " because the one definition of it was added after the start and" + + " removed again before the close - neither list the close works" + + " through ever names it, but its chain published the registration"); + Assertions.assertFalse(SlowQueryReportJmx.mbeans.containsKey(poolName), + "and the registry of published names must not keep the name either"); + } finally { + ds.close(); + } + } } diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxMBeanSurface.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxMBeanSurface.java index 797e1998d5..70b04af034 100644 --- a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxMBeanSurface.java +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxMBeanSurface.java @@ -17,15 +17,22 @@ package org.apache.tomcat.jdbc.pool.interceptor; import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.sql.Connection; import java.sql.Statement; import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.management.MBeanAttributeInfo; import javax.management.MBeanInfo; +import javax.management.MBeanNotificationInfo; import javax.management.MBeanOperationInfo; import javax.management.MBeanServer; import javax.management.ObjectName; @@ -117,6 +124,64 @@ class TestSlowQueryJmxMBeanSurface extends JdbcPoolBaseTest { } } + @Test + void mBeanInfoAdvertisesTheNotificationTypesTheMBeanSends() throws Exception { + // JMX clients discover what an MBean can send through + // MBeanInfo.getNotifications() - consoles, monitoring and bridge + // agents, tooling that builds a NotificationFilterSupport from the + // advertised types. An MBean that sends its queries as the two wire + // types of this class but advertises none of them leaves that tooling + // blind whatever delivery itself does (the pool's own MBean declares + // its types the same way, see TestJmxNotificationTypeContract). + String poolName = "JmxNotifInfo-" + System.nanoTime(); + DataSource ds = reportingPoolNamed(poolName); + + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName oname = objectNameFor(poolName); + + try (@SuppressWarnings("unused") Connection con = ds.getConnection()) { + Assertions.assertTrue(server.isRegistered(oname), + "the premise: the interceptor MBean must be registered"); + MBeanNotificationInfo[] advertisedInfo = server.getMBeanInfo(oname).getNotifications(); + Set<String> advertised = advertisedInfo == null ? new HashSet<>() + : Arrays.stream(advertisedInfo).flatMap(i -> Arrays.stream(i.getNotifTypes())) + .collect(Collectors.toSet()); + Assertions.assertEquals(new HashSet<>(Arrays.asList( + SlowQueryReportJmx.SLOW_QUERY_NOTIFICATION, + SlowQueryReportJmx.FAILED_QUERY_NOTIFICATION)), advertised, + "the published MBean must advertise exactly the notification types it sends"); + } + } + + @Test + void theDescriptorAdvertisesTheWireTypesTheNotificationsCarry() throws Exception { + // mbeans-descriptors.xml is this MBean's legacy metadata, and like the + // published MBeanInfo it is material a tool builds its filters from: + // a notification type that differs from the wire string makes a filter + // built from the metadata match nothing. The constants are the wire + // contract (see TestJmxNotificationTypeContract), so the descriptor + // must carry exactly them, and no half-written descriptions. + Path descriptor = Paths.get("src", "main", "java", "org", "apache", "tomcat", + "jdbc", "pool", "interceptor", "mbeans-descriptors.xml"); + String xml = new String(Files.readAllBytes(descriptor), StandardCharsets.UTF_8); + + Set<String> advertised = new HashSet<>(); + Matcher types = Pattern.compile("<notification-type>([^<]+)</notification-type>") + .matcher(xml); + while (types.find()) { + advertised.add(types.group(1)); + } + Assertions.assertEquals(new HashSet<>(Arrays.asList( + SlowQueryReportJmx.SLOW_QUERY_NOTIFICATION, + SlowQueryReportJmx.FAILED_QUERY_NOTIFICATION)), advertised, + "the descriptor must name the notification types by the exact strings the" + + " notifications carry on the wire - a client building its filter" + + " from the metadata must match what the MBean actually sends"); + + Assertions.assertFalse(Pattern.compile("description=\"[^\"]*\\s\"").matcher(xml).find(), + "no description may dangle half-written on a trailing space"); + } + @Test void poolNameAndPoolNamesAreReadableThroughTheServer() throws Exception { String poolName = "JmxPoolName-" + System.nanoTime(); diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxNotifications.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxNotifications.java index 7476786461..f03511bd67 100644 --- a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxNotifications.java +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryJmxNotifications.java @@ -25,15 +25,20 @@ import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import javax.management.InstanceNotFoundException; import javax.management.ListenerNotFoundException; import javax.management.MBeanServer; +import javax.management.MalformedObjectNameException; import javax.management.Notification; import javax.management.NotificationListener; import javax.management.ObjectName; @@ -44,6 +49,8 @@ import org.junit.jupiter.api.Test; import org.apache.tomcat.jdbc.JdbcPoolBaseTest; import org.apache.tomcat.jdbc.pool.DataSource; import org.apache.tomcat.jdbc.pool.JdbcInterceptor; +import org.apache.tomcat.jdbc.pool.PoolProperties; +import org.apache.tomcat.jdbc.pool.PoolProperties.InterceptorProperty; /** * JMX notifications of the {@code SlowQueryReportJmx} interceptor in @@ -77,7 +84,11 @@ import org.apache.tomcat.jdbc.pool.JdbcInterceptor; * same-named pool has taken over, nor stop at anything but the ObjectName * the registration was published under: the registry records the name its * owner registered, and a release that finds a live pool other than the - * releasing one under the name leaves the whole name with that pool.</p> + * releasing one under the name leaves the whole name with that pool. The + * publication itself runs as a later step of the slot-taking borrow, so a + * publish can also land behind a release of its own slot - and must then + * undo itself, leaving the name publishable by the next pool instead of + * orphaned forever.</p> * <p> * The notifications themselves must also be deliverable beyond this * JVM: {@code Notification} marshals its source, so the source has to be @@ -255,6 +266,57 @@ class TestSlowQueryJmxNotifications extends JdbcPoolBaseTest { + " registered under, not by the default shape or the instance"); } + @Test + void everyDefinitionOfANameSendsTheNameTheRegistrationWasPublishedUnder() throws Exception { + String poolName = "JmxSourceTwoDefinitions-" + System.nanoTime(); + String customName = "tomcat.jdbc:type=SlowQueryOverride_" + System.nanoTime(); + DataSource ds = newDataSource(); + ds.setName(poolName); + // Two reporting definitions in one chain - the shape the two-thresholds + // configuration documents: the first definition in the chain takes the + // registration slot and publishes under its configured name, and the + // second definition's instances never publish - yet every instance of + // the name reports its slow queries through the name's broadcaster, + // which the published MBean answers on. + ds.setJdbcInterceptors("org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReportJmx" + + "(notifyPool=false,threshold=0,objectName=" + customName + ");" + + "org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReportJmx" + + "(notifyPool=false,threshold=0)"); + ds.getPoolProperties().setInitialSize(0); + + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName customOname = new ObjectName(customName); + ObjectName defaultOname = new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",name=" + poolName); + List<Notification> received = new CopyOnWriteArrayList<>(); + NotificationListener listener = (notification, handback) -> received.add(notification); + + try (Connection con = ds.getConnection()) { + Assertions.assertTrue(server.isRegistered(customOname), + "the first definition of the chain wins the name and publishes under" + + " its configured ObjectName"); + Assertions.assertFalse(server.isRegistered(defaultOname), + "the second definition must not publish a second MBean under the name"); + interceptorOf(con, SlowQueryReportJmx.class) + .addNotificationListener(listener, null, null); + try (Statement st = con.createStatement()) { + st.execute(slowSql(1)); + } + } + + Assertions.assertEquals(2, received.size(), + "both reporting definitions of the chain see the query and notify"); + for (Notification notification : received) { + Assertions.assertEquals(customOname, notification.getSource(), + "every notification of a pool name must carry the ObjectName the" + + " registration was published under: a non-publishing instance" + + " recomputing the source from its own properties stamps the" + + " notification with a name nothing is registered under, so a" + + " client filtering by source silently misses it, and a client" + + " grouping by source sees a phantom MBean"); + } + } + @Test void notificationSourceFallsBackToAStringWhenTheObjectNameCannotBeFormed() throws Exception { // a pool name containing a colon cannot be quoted into the @@ -645,6 +707,67 @@ class TestSlowQueryJmxNotifications extends JdbcPoolBaseTest { } } + /* + * A reporting interceptor that counts the ObjectNames its borrows form. + * Once a pool name holds its registration, a borrow cannot publish a + * second MBean under it - the slot race is already lost to the pool's own + * registration - so forming the name and building a Registration for that + * losing race is work a healthy pool pays per connection, per borrow. + */ + public static class CountingFormationReport extends SlowQueryReportJmx { + @Override + public ObjectName getObjectName(Class<?> clazz, String poolName) + throws MalformedObjectNameException { + NAME_FORMATIONS.computeIfAbsent(String.valueOf(poolName), k -> new AtomicInteger()) + .incrementAndGet(); + return super.getObjectName(clazz, poolName); + } + } + + private static final Map<String, AtomicInteger> NAME_FORMATIONS = new ConcurrentHashMap<>(); + + private static int nameFormations(String poolName) { + AtomicInteger count = NAME_FORMATIONS.get(poolName); + return count == null ? 0 : count.get(); + } + + @Test + void aRegisteredNameCostsNoFurtherNameFormationPerBorrow() throws Exception { + String poolName = "JmxFormation-" + System.nanoTime(); + DataSource ds = reportingPoolNamed(poolName, + CountingFormationReport.class.getName() + "(notifyPool=false,threshold=0)"); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName oname = new ObjectName("tomcat.jdbc:type=" + + CountingFormationReport.class.getName() + ",name=" + poolName); + try { + try (@SuppressWarnings("unused") Connection first = ds.getConnection()) { + Assertions.assertTrue(server.isRegistered(oname), + "the premise: the first borrow published the MBean under the name"); + int formationsAtRegistration = nameFormations(poolName); + Assertions.assertTrue(formationsAtRegistration >= 1, + "the borrow that publishes the name must have formed it"); + + try (@SuppressWarnings("unused") Connection second = ds.getConnection()) { + // nothing to do: the borrow itself is the measured step + } + try (@SuppressWarnings("unused") Connection third = ds.getConnection()) { + // a reused connection exercises the same path again + } + + Assertions.assertEquals(formationsAtRegistration, nameFormations(poolName), + "a name that already holds its registration must be noticed before" + + " the name is formed and a Registration built to lose the" + + " slot race: parsing a full ObjectName and allocating a" + + " publication that cannot happen on every borrow of a" + + " perfectly registered pool is cost with no possible use -" + + " and a refused registration still leaves the slot free, so" + + " the retry semantics of a failed publish are untouched"); + } + } finally { + ds.close(); + } + } + @Test void failedRegistrationFreesTheSlotSoALaterBorrowCanStillPublish() throws Exception { String poolName = "JmxRegFail-" + System.nanoTime(); @@ -793,6 +916,40 @@ class TestSlowQueryJmxNotifications extends JdbcPoolBaseTest { } } + @Test + void aNotificationSentAfterTheNameWasReleasedDoesNotBringTheNameBack() throws Exception { + String poolName = "JmxLateNotify-" + System.nanoTime(); + DataSource ds = reportingPoolNamed(poolName); + SlowQueryReportJmx reporter; + try (Connection con = ds.getConnection()) { + reporter = interceptorOf(con, SlowQueryReportJmx.class); + try (Statement st = con.createStatement()) { + st.execute(slowSql(1)); + } + Assertions.assertNotNull(SlowQueryReportJmx.notifiers.get(poolName), + "the premise: reporting a query of a live pool reaches the name's" + + " broadcaster, which is created for it"); + } + ds.close(); + Assertions.assertNull(SlowQueryReportJmx.notifiers.get(poolName), + "the premise: closing the last pool of the name released that broadcaster"); + + // A query that was already running while the pool came down reports + // when it finishes, and the interceptor instance that served it outlives + // the close holding the name it reported under. + reporter.notifyJmx("SELECT 1", SlowQueryReportJmx.SLOW_QUERY_NOTIFICATION); + + Assertions.assertNull(SlowQueryReportJmx.notifiers.get(poolName), + "a notification sent for a name that is released must not put that name back" + + " in the static registry: nothing that runs for the dead name will" + + " take it down again, so the entry - and any listener a client still" + + " holds on the broadcaster it comes with, and the class loader behind" + + " it - outlive the pool by the life of the JVM, until some later pool" + + " happens to use the same name"); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "and the send must not leave anything else registered under the released name"); + } + @Test void concurrentClosesOfSameNamedPoolsReleaseTheNameExactlyOnce() throws Exception { String poolName = "JmxRace-" + System.nanoTime(); @@ -1026,4 +1183,345 @@ class TestSlowQueryJmxNotifications extends JdbcPoolBaseTest { } } } + + @Test + void aRegistrationCompletedBehindItsOwnReleaseLeavesNothingPublished() throws Exception { + String poolName = "JmxLatePublish-" + System.nanoTime(); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName oname = new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",name=" + poolName); + + // registerJmx takes the per-name registry slot and publishes to the + // MBeanServer as two separate steps. The race this test pins is a + // release running between them: it takes the slot out and unregisters + // the name - which nothing has published under yet, so the + // unregistration finds nothing - and the publish then completes + // behind it, with no live state left that still knows about it. The + // window inside one borrow is a thread race no test can force + // deterministically, so - as in the late-release test above - the test + // drives the two decided steps apart in the order the race leaves them. + + // The state after registerJmx's first step: the slot holds the + // registration the borrow is about to publish. + SlowQueryReportJmx.Registration inFlight = + new SlowQueryReportJmx.Registration(poolName, oname); + Assertions.assertNull(SlowQueryReportJmx.mbeans.putIfAbsent(poolName, inFlight), + "the test's premise: the per-name slot must start free"); + + // The release that lands inside the window: it takes the slot it read + // out of the registry and unregisters the ObjectName that registration + // records - a name the MBeanServer has never seen. + SlowQueryReportJmx releaser = new SlowQueryReportJmx(); + releaser.poolName = poolName; + releaser.deregisterJmx(); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "the release must have let go of the slot the in-flight publish held"); + + // The stalled publish now completes. The MBeanServer takes it: the + // name was never published, so nothing collides with it. A registration + // that does not check its own publication against the registry is + // published from here on with nothing tracking it: no pool, no + // registry entry, no live bookkeeping - an orphan until the JVM exits, + // holding the name every later pool under it would register under. + server.registerMBean(inFlight, oname); + Assertions.assertFalse(server.isRegistered(oname), + "a publish that lands behind the release of its own slot must undo" + + " itself - the registration is the only object that can" + + " still tell its publication apart from a taken one"); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "the undone publish must not leave a registry entry behind"); + + // The name must stay usable: a pool started after the undone publish + // must be able to register it cleanly, and release it again on close. + DataSource revived = reportingPoolNamed(poolName); + try (@SuppressWarnings("unused") Connection borrowed = revived.getConnection()) { + Assertions.assertTrue(server.isRegistered(oname), + "the first borrow of a pool started after the undone publish must" + + " publish the name - the undone orphan must not poison it"); + } + revived.close(); + Assertions.assertFalse(server.isRegistered(oname), + "closing the pool that took the name after the undone publish must release it"); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "and release the per-name registry slot with it"); + } + + @Test + void aRegistrationCompletedBehindAReleaseAndATakeoverLeavesTheSuccessorAlone() + throws Exception { + String poolName = "JmxLatePostRegister-" + System.nanoTime(); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName oname = new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",name=" + poolName); + + // The undo in postRegister is aimed at the name of its own publish - + // which is exactly the name a takeover under the same pool name + // registers under. The race this test pins has three steps inside the + // window between the server taking a registration and calling its + // postRegister: the release of the name lands after the commit (so it + // takes this publication down), and a new pool then finds the slot and + // the name both free and publishes itself. When the stalled + // postRegister finally runs, the slot holds the successor - and a + // registration that reads "not my slot" as "no slot" takes the + // successor's live MBean down under it. As in the test above, the + // decided steps are driven apart in the order the race leaves them. + + // The borrow's registration: slot taken, publication committed. + SlowQueryReportJmx.Registration inFlight = + new SlowQueryReportJmx.Registration(poolName, oname); + Assertions.assertNull(SlowQueryReportJmx.mbeans.putIfAbsent(poolName, inFlight)); + server.registerMBean(inFlight, oname); + + // Step one inside the window: the release, which takes the slot it + // read out of the registry and unregisters the name this publication + // occupies - the release runs against the recorded name, so it does + // take this publication down. + SlowQueryReportJmx releaser = new SlowQueryReportJmx(); + releaser.poolName = poolName; + releaser.deregisterJmx(); + Assertions.assertFalse(server.isRegistered(oname), + "the premise of the takeover below: this release ran after the commit, so its" + + " unregistration took this publication down - which is also what made" + + " the name free again"); + + // Step two: a new pool under the same name finds the slot and the name + // free - freed by that very release - and publishes cleanly. + SlowQueryReportJmx.Registration successor = + new SlowQueryReportJmx.Registration(poolName, oname); + Assertions.assertNull(SlowQueryReportJmx.mbeans.putIfAbsent(poolName, successor), + "the release must have left the slot free for the takeover"); + server.registerMBean(successor, oname); + Assertions.assertTrue(server.isRegistered(oname), + "the successor's publication must have been accepted"); + + // The stalled postRegister of the first registration now runs - what + // the server would call at the end of the interrupted registerMBean. + inFlight.postRegister(Boolean.TRUE); + + try { + Assertions.assertTrue(server.isRegistered(oname), + "a late postRegister must leave the successor's MBean alone: the slot" + + " holding another registration means that release already took" + + " this publication down - there is nothing left of it to undo -" + + " and unregistering the name now destroys the live MBean of the" + + " pool that took the name over, silently and with the registry" + + " still claiming it is published"); + Assertions.assertSame(successor, SlowQueryReportJmx.mbeans.get(poolName), + "the successor must still hold the registry slot it took"); + } finally { + SlowQueryReportJmx janitor = new SlowQueryReportJmx(); + janitor.poolName = poolName; + janitor.deregisterJmx(); + if (server.isRegistered(oname)) { + server.unregisterMBean(oname); + } + } + } + + @Test + void aLatePublishUnderANameTheRegistryNoLongerRecordsUndoesItself() throws Exception { + String poolName = "JmxLatePublishOtherName-" + System.nanoTime(); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName oname = new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",name=" + poolName); + // Two definitions of one pool name may compute different ObjectNames - + // the configured override of one and the default shape of the other is + // enough (see registrationObjectName), and that is what separates this + // race from the one above. + ObjectName successorName = new ObjectName( + "tomcat.jdbc:type=LatePublishSuccessor_" + System.nanoTime()); + + // Driven in the order the race leaves the steps: the release lands + // while this publish is in flight, so its unregistration finds nothing + // to take down; a second pool under the same name, configured with a + // name of its own, takes the free slot and publishes there; and only + // then does the stalled publish commit. + SlowQueryReportJmx.Registration inFlight = + new SlowQueryReportJmx.Registration(poolName, oname); + Assertions.assertNull(SlowQueryReportJmx.mbeans.putIfAbsent(poolName, inFlight)); + + SlowQueryReportJmx releaser = new SlowQueryReportJmx(); + releaser.poolName = poolName; + releaser.deregisterJmx(); + + SlowQueryReportJmx.Registration successor = + new SlowQueryReportJmx.Registration(poolName, successorName); + Assertions.assertNull(SlowQueryReportJmx.mbeans.putIfAbsent(poolName, successor), + "the release must have left the slot free for the takeover"); + server.registerMBean(successor, successorName); + + try { + // The stalled publish commits last. The server calls its + // postRegister at the end of this call, and that is where the undo + // has to decide: the slot is held, but by a registration published + // under another name, so nothing accounts for this one. + server.registerMBean(inFlight, oname); + + Assertions.assertFalse(server.isRegistered(oname), + "a publish that lands behind the release of its slot must undo itself even" + + " when the slot is meanwhile held: the successor publishes under" + + " its own name, so the registry records no name this publication" + + " could ever be given up by - reading the occupied slot as" + + " accounting for it leaves this MBean published for the life of" + + " the JVM, under a name no release of the pool can reach"); + Assertions.assertTrue(server.isRegistered(successorName), + "and the undo must leave the successor's own publication alone"); + Assertions.assertSame(successor, SlowQueryReportJmx.mbeans.get(poolName), + "the successor must still hold the registry slot it took"); + } finally { + SlowQueryReportJmx janitor = new SlowQueryReportJmx(); + janitor.poolName = poolName; + janitor.deregisterJmx(); + for (ObjectName name : new ObjectName[] { oname, successorName }) { + if (server.isRegistered(name)) { + server.unregisterMBean(name); + } + } + } + } + + @Test + void aMalformedObjectNameOverrideFailsOnlyTheRegistration() throws Exception { + // An override value that is not an ObjectName at all (no domain + // separator) fails the name formation on every borrow, before the + // registry slot and its refusal throttle are reachable - the failure + // is reported each time (see the acceptability comment in + // registerJmx). What must stay true while it is: the failure touches + // nothing but the monitoring, never latches the slot, and leaves + // nothing under the name behind. + String poolName = "JmxMalformedOname-" + System.nanoTime(); + DataSource ds = reportingPoolNamed(poolName, + "org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReportJmx" + + "(notifyPool=false,threshold=-1,objectName=value-without-a-domain)"); + + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + Set<ObjectName> registeredBefore = server.queryNames(new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",*"), null); + try (@SuppressWarnings("unused") Connection first = ds.getConnection()) { + // the borrow itself served: a failed name formation cannot fail + // the pool's operation, only its monitoring + Assertions.assertEquals(registeredBefore, server.queryNames(new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",*"), null), + "no registration may appear while the name cannot be formed"); + } + try (@SuppressWarnings("unused") Connection second = ds.getConnection()) { + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "a name that cannot be formed must never latch the registry slot"); + Assertions.assertNull(SlowQueryReportJmx.registrationFailuresLogged.get(poolName), + "and nothing may join the per-name bookkeeping through a failure" + + " that cannot form its ObjectName"); + } + + ds.close(); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "and closing the pool leaves no registry entry under the name"); + Assertions.assertEquals(registeredBefore, server.queryNames(new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",*"), null), + "and nothing under the type for this pool"); + } + + @Test + void aNullObjectNamePropertyValueBehavesAsIfAbsent() throws Exception { + // The property grammar cannot produce a value-less property, but the + // public programmatic API can: InterceptorProperty carries a null + // value, and a definition handed through it reaches the chain with + // one. A null value names nothing to override - the same reading + // every sibling property of this family gives a null value - so the + // default name applies and everything else must hold as if the + // property were absent. Before the guard the null reached the + // ObjectName constructor as an NPE that registerJmx does not catch, + // escaping through reset() to fail every borrow of the pool and + // discard the physical connection each attempt had just opened. + String poolName = "JmxNullOname-" + System.nanoTime(); + DataSource ds = reportingPoolNamed(poolName); + for (PoolProperties.InterceptorDefinition definition + : ds.getPoolProperties().getJdbcInterceptorsAsArray()) { + if (definition.getClassName().endsWith("SlowQueryReportJmx")) { + definition.addProperty("objectName", null); + } + } + + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + ObjectName oname = new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",name=" + poolName); + try (@SuppressWarnings("unused") Connection first = ds.getConnection()) { + Assertions.assertTrue(server.isRegistered(oname), + "a value-less objectName property must cost no more than any other" + + " unformable override - the borrow itself must be served, and" + + " the registration fall back to the default name of the pool"); + } + ds.close(); + Assertions.assertFalse(server.isRegistered(oname), + "and closing the pool releases the name again"); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "with no registry entry left behind"); + } + + @Test + void aReleaseThatCannotDeriveTheNameKeepsTheRegistrationReleasable() throws Exception { + // The release unregisters the ObjectName that registrationObjectName() + // derives for the registry value, and that derivation is the one step + // of the release that can throw - for a registration a subclass put + // into the open registry, whose own properties no longer form the name + // the MBeanServer holds. A release that cannot work out which name to + // take down must leave the registry slot held: a registry that has + // forgotten the registration while the MBeanServer still publishes it + // is the one state no later release can repair. As in the late-publish + // test, the test drives the registry state directly and runs the + // failing release and the later repairing one apart. + String poolName = "JmxBadRelease-" + System.nanoTime(); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + + // The registry value and its published MBean, as registration would + // have left them: derived name, live registration, live bookkeeping. + SlowQueryReportJmx owner = new SlowQueryReportJmx(); + owner.poolName = poolName; + ObjectName oname = owner.getObjectName(SlowQueryReportJmx.class, poolName); + server.registerMBean(owner, oname); + SlowQueryReportJmx.mbeans.put(poolName, owner); + owner.getNotifier(); + SlowQueryReportJmx.registrationFailuresLogged.put(poolName, oname); + + // Reconfigure the owner so the release's recomputation of its name + // fails, as it does whenever the owner's own properties no longer form + // the name the MBeanServer holds. + Map<String, InterceptorProperty> malformed = new HashMap<>(); + malformed.put(SlowQueryReportJmx.objectNameAttribute, new InterceptorProperty( + SlowQueryReportJmx.objectNameAttribute, "value-without-a-domain")); + owner.setProperties(malformed); + + SlowQueryReportJmx releaser = new SlowQueryReportJmx(); + releaser.poolName = poolName; + releaser.deregisterJmx(); + + // The release could not name the MBean to take down - the MBean is + // still published, so the registry must still be holding the value + // that names it. Dropping the slot on the way to the failed + // derivation strands the MBean behind a registry that no longer knows + // about it: no later release can find the registration to unregister, + // and every later pool under the name is refused its own. + Assertions.assertSame(owner, SlowQueryReportJmx.mbeans.get(poolName), + "a release whose derivation fails must leave the registry slot held"); + Assertions.assertTrue(server.isRegistered(oname), + "the premise: the MBean is still published while the release failed"); + + // The broadcaster and the throttle marker belong to the name as much + // as the MBean does (see deregisterJmx's javadoc): the release drops + // them even when its own unregistration step failed. + Assertions.assertNull(SlowQueryReportJmx.notifiers.get(poolName), + "the name's broadcaster must be released with the name"); + Assertions.assertNull(SlowQueryReportJmx.registrationFailuresLogged.get(poolName), + "and the registration-failure throttle with it"); + + // The name must stay releasable: once the derivation works again - + // here, the owner's properties no longer carry the malformed override + // - a later release of the name must still find the slot and take the + // MBean down with it. + owner.setProperties(new HashMap<>()); + releaser.deregisterJmx(); + Assertions.assertFalse(server.isRegistered(oname), + "a later release with a derivable name must still be able to unregister the MBean"); + Assertions.assertNull(SlowQueryReportJmx.mbeans.get(poolName), + "and take the registry slot with it"); + } } diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryReportNamelessPool.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryReportNamelessPool.java index 3ce7c4a6ec..933eb49569 100644 --- a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryReportNamelessPool.java +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/interceptor/TestSlowQueryReportNamelessPool.java @@ -16,14 +16,24 @@ */ package org.apache.tomcat.jdbc.pool.interceptor; +import java.io.Serializable; +import java.lang.management.ManagementFactory; +import java.sql.Connection; import java.sql.SQLException; +import java.sql.Statement; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import javax.management.MBeanServer; +import javax.management.Notification; +import javax.management.NotificationListener; +import javax.management.ObjectName; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -46,11 +56,21 @@ import org.apache.tomcat.jdbc.pool.DataSource; * {@code getPoolStats(null)} threw rather than answering the same * {@code null} it answers for any other name that holds nothing. * <p> - * The tests pin the two halves of the corrected contract: a lookup of a name - * that can never be keyed answers {@code null} like any other absent name - - * which is also the answer the borrow-time reference in {@code reset} resolves, - * since it comes through this lookup - and a pool that starts without a name is - * refused by a failure that says which setting is missing. + * The tests pin the halves of the corrected contract: a lookup of a name that + * can never be keyed answers {@code null} like any other absent name - which is + * also the answer the borrow-time reference in {@code reset} resolves, since it + * comes through this lookup - and a pool that starts without a name is refused + * by a failure that says which setting is missing. + * </p> + * <p> + * The refusal only covers a definition the pool was started with, because a + * reporting definition is refused at its start. The interceptor string is + * mutable while the pool runs, so a definition the pool gains after its start + * never meets that check, and the nameless pool and its unformable name arrive + * at the borrow-time registration side by side. A borrow is not the place to + * learn that nothing can be registered under no name: what the missing key can + * cost there is the monitoring, never the query - so the last tests pin that a + * late definition costs a nameless pool neither its borrows nor its statements. * </p> */ class TestSlowQueryReportNamelessPool extends JdbcPoolBaseTest { @@ -110,6 +130,93 @@ class TestSlowQueryReportNamelessPool extends JdbcPoolBaseTest { } } + @Test + void aJmxReportingDefinitionAddedToANamelessPoolAfterTheStartStillServesItsBorrows() + throws Exception { + // A definition the pool gains after its start never runs poolStarted, + // so the refusal above cannot reach it: this pairing exists, and its + // borrow-time registration is where the missing key turns up. Nothing + // can be registered under no name, and a borrow is not the place where + // that is decided - the cost of the missing key is the monitoring. + DataSource ds = namelessPoolWith(""); + MBeanServer server = ManagementFactory.getPlatformMBeanServer(); + try { + ds.createPool(); + ds.setJdbcInterceptors(SlowQueryReportJmx.class.getName() + + "(notifyPool=false,threshold=0)"); + Assertions.assertDoesNotThrow(() -> { + try (@SuppressWarnings("unused") Connection con = ds.getConnection()) { + // the borrow itself is the measured step + } + }, "a reporting definition the pool took on after its start must not cost a" + + " nameless pool its borrows: the NPE out of the name-keyed registry" + + " escapes the interceptor reset and fails every connection the pool" + + " hands out, discarding the physical connection each attempt opened"); + + try (@SuppressWarnings("unused") Connection con = ds.getConnection()) { + // borrowed - now make sure the quietly declined registration + // declined rather than published under a made-up name + Assertions.assertTrue(server.queryNames(new ObjectName("tomcat.jdbc:type=" + + SlowQueryReportJmx.class.getName() + ",*"), null).stream() + .noneMatch(name -> "null".equals(name.getKeyProperty("name"))), + "a pool with no name has no name to publish under: declining the" + + " registration must not become a MBean under the literal" + + " text of a null reference"); + } + } finally { + ds.close(); + } + } + + @Test + void aSlowQueryOfANamelessPoolWithALateReportingDefinitionIsServedAndStillNotified() + throws Exception { + // Same pairing, met one step later: the slow-query notification names + // its source by the registration the pool's name holds, and a name + // that does not exist is not a key the registry can be asked about. + // The proxy keeps a report from disturbing the application, so the + // statement does come back - what the missing key silently takes is + // the notification itself, and a monitoring channel that is configured + // and quietly sends nothing is the outcome the class refuses everywhere + // else for a name it cannot form. + DataSource ds = namelessPoolWith(""); + try { + ds.createPool(); + ds.setJdbcInterceptors(SlowQueryReportJmx.class.getName() + + "(notifyPool=false,threshold=0)"); + try (Connection admin = adminConnection(); + Statement setup = admin.createStatement()) { + setup.execute("CREATE ALIAS IF NOT EXISTS SLOW_SLEEP FOR " + + "\"java.lang.Thread.sleep(long)\""); + } + String slow = "SELECT 1 FROM SYSTEM_RANGE(1,1) WHERE SLOW_SLEEP(25) IS NULL"; + try (Connection con = ds.getConnection()) { + List<Notification> received = new CopyOnWriteArrayList<>(); + NotificationListener listener = (notification, handback) -> received.add(notification); + interceptorOf(con, SlowQueryReportJmx.class).addNotificationListener(listener, null, null); + Assertions.assertDoesNotThrow(() -> { + try (Statement st = con.createStatement()) { + st.execute(slow); + } + }, "a slow query of a pool that carries no name must still come back as a" + + " executed statement"); + Assertions.assertEquals(1, received.size(), + "the slow query must still be reported to the listeners the" + + " interceptor has: the source of a notification may lose the" + + " registered name it would rather carry, but a source it" + + " cannot look up must not cost the notification - the" + + " failure rides through as a swallowed warning, so the" + + " monitoring of a nameless pool is simply gone"); + Assertions.assertInstanceOf(Serializable.class, received.get(0).getSource(), + "the source of a notification is marshalled to listeners outside this" + + " JVM, so even the fallback for a pool that carries no name" + + " must be something that survives that delivery"); + } + } finally { + ds.close(); + } + } + @Test void aPoolThatNeverSetsTheNamePropertyIsNotANamelessPool() throws Exception { try (StartLogCapture logs = new StartLogCapture()) { diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/jmx/TestJmxRegistration.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/jmx/TestJmxRegistration.java index da789cb1d9..ffe85fa19b 100644 --- a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/jmx/TestJmxRegistration.java +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/jmx/TestJmxRegistration.java @@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicReference; import javax.management.InstanceAlreadyExistsException; import javax.management.MBeanRegistration; +import javax.management.MBeanRegistrationException; import javax.management.MBeanServer; import javax.management.MalformedObjectNameException; import javax.management.Notification; @@ -60,7 +61,10 @@ import org.apache.tomcat.jdbc.pool.interceptor.StatementCache; * perfectly well.</p> * <p> * The contract the first tests pin is that the returned name is this call's own - * publish, or nothing at all. The last five drive that through a real pool: a + * publish, or nothing at all. The unregistration tests pin the mirror: what the + * helper reports when it takes a name down, since every holder drops its handle + * the moment it calls, and a refusal it cannot see leaves the MBean registered + * for the life of the JVM. The pool-level tests drive that through a real pool: a * {@code StatementCache} whose name is taken must leave the MBean that owns the * name registered when its connection is discarded, and it must publish itself * at the next borrow once the obstacle is gone; the {@code DataSource} one @@ -103,6 +107,47 @@ class TestJmxRegistration extends JdbcPoolBaseTest { } } + /* + * An MBean that refuses to be unregistered until told otherwise: the + * MBeanRegistrationException a throwing preDeregister() turns into a + * refused unregisterMBean() call, which is how a deregistration fails + * besides the name simply being gone. + */ + public interface StubbornMBean { + String getMarker(); + } + + public static class Stubborn implements StubbornMBean, MBeanRegistration { + private volatile boolean allowRelease; + + @Override + public String getMarker() { + return "stubborn"; + } + + @Override + public ObjectName preRegister(MBeanServer server, ObjectName name) { + return name; + } + + @Override + public void postRegister(Boolean registrationDone) { + // nothing to do once published + } + + @Override + public void preDeregister() throws Exception { + if (!allowRelease) { + throw new MBeanRegistrationException(null, "this MBean gives up its name only once"); + } + } + + @Override + public void postDeregister() { + // nothing to undo + } + } + /* * A modeler-style wrapper of the kind a container registers in place of a * DataSource. The DataSource is not itself a JMX compliant MBean, so the @@ -357,6 +402,62 @@ class TestJmxRegistration extends JdbcPoolBaseTest { "a malformed name must publish nothing"); } + @Test + void aSuccessfulUnregistrationComesBackAsReleased() throws Exception { + MBeanServer server = server(); + ObjectName name = uniqueName("released"); + server.registerMBean(new Probe("released"), name); + + Assertions.assertTrue(JmxUtil.unregisterJmx(name), + "an unregistration the server carried out must be reported as done: every" + + " caller of this helper drops its handle the moment it calls, so a" + + " release that silently did not happen is undetectable from the" + + " calling side"); + Assertions.assertFalse(server.isRegistered(name)); + } + + @Test + void aRefusedUnregistrationComesBackAsNotReleased() throws Exception { + MBeanServer server = server(); + ObjectName name = uniqueName("stubborn"); + Stubborn mbean = new Stubborn(); + server.registerMBean(mbean, name); + try { + Assertions.assertFalse(JmxUtil.unregisterJmx(name), + "an unregistration the MBean refused - a preDeregister() that throws, as a" + + " modeler wrapper's failing postDeregister() also produces - must" + + " come back as not done: the MBean is still registered under a" + + " name its caller is about to forget, which is exactly the" + + " failure the caller cannot see any other way, and it leaves a" + + " live management surface for a dead component holding its" + + " credentials and class loader until the JVM exits"); + Assertions.assertTrue(server.isRegistered(name), + "a refused unregistration must have left the MBean published - that is" + + " the state the caller has to be warned about"); + } finally { + mbean.allowRelease = true; + server.unregisterMBean(name); + } + } + + @Test + void anUnregistrationOfNothingCountsAsDone() throws Exception { + MBeanServer server = server(); + ObjectName gone = uniqueName("already-gone"); + server.registerMBean(new Probe("gone"), gone); + server.unregisterMBean(gone); + + // the MBean is already down - the desired end state, reached without + // this call - and no caller has anything left to do differently + Assertions.assertTrue(JmxUtil.unregisterJmx(gone), + "unregistering a name that is already gone must be reported as done, not as a" + + " failure: an InstanceNotFoundException means the world is already" + + " in the state the caller asked for, and grading it as a refusal" + + " would hide the refusals that are real"); + Assertions.assertTrue(JmxUtil.unregisterJmx(null), + "releasing nothing is the same end state and the same answer"); + } + @Test void aCacheThatWasRefusedItsNameDoesNotUnregisterTheMBeanThatOwnsIt() throws Exception { MBeanServer server = server(); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
