diff --git a/docs/using-the-jdbc-driver/UsingTheJdbcDriver.md b/docs/using-the-jdbc-driver/UsingTheJdbcDriver.md index 829b4f38a..85d0ae314 100644 --- a/docs/using-the-jdbc-driver/UsingTheJdbcDriver.md +++ b/docs/using-the-jdbc-driver/UsingTheJdbcDriver.md @@ -84,7 +84,7 @@ These parameters are applicable to any instance of the AWS Advanced JDBC Wrapper | `user` | `String` | No | Database username. | `null` | | `password` | `String` | No | Database password. | `null` | | `wrapperDialect` | `String` | No | Please see [this page on database dialects](./DatabaseDialects.md), and whether you should include it. | `null` | -| `wrapperLogUnclosedConnections` | `Boolean` | No | Allows the AWS Advanced JDBC Wrapper to capture a stacktrace for each connection that is opened. If the `finalize()` method is reached without the connection being closed, the stacktrace is printed to the log. This helps developers to detect and correct the source of potential connection leaks. | `false` | +| `wrapperLogUnclosedConnections` | `Boolean` | No | Allows the AWS Advanced JDBC Wrapper to capture a stacktrace for each connection that is opened. If the connection is garbage collected without being closed, the stacktrace is printed to the log. This helps developers to detect and correct the source of potential connection leaks. | `false` | | `loginTimeout` | `Integer` | No | Login timeout in milliseconds. | `null` | | `connectTimeout` | `Integer` | No | Socket connect timeout in milliseconds. | `null` | | `socketTimeout` | `Integer` | No | Socket timeout in milliseconds. | `null` | @@ -98,6 +98,19 @@ These parameters are applicable to any instance of the AWS Advanced JDBC Wrapper | `wrapperCaseSensitive`,
`wrappercasesensitive` | `Boolean` | No | Allows the driver to change case sensitivity for parameter names in the connection string and in connection properties. Set parameter to `false` to allow case-insensitive parameter names. | `true` | | `skipWrappingForPackages` | `String` | No | Register Java package names (separated by comma) which will be left unwrapped. This setting modifies all future connections established by the driver, not just a particular connection. | `com.pgvector` | +## System Properties + +These Java system properties can be set using `-D` flags when starting the JVM to configure global behavior of the AWS Advanced JDBC Wrapper. + +| Property | Value | Description | Default Value | +|---------------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| `aws.jdbc.cleanup.thread.ttl` | `Long` | Time-to-live in milliseconds for the cleanup thread that monitors unclosed connections. The thread will automatically stop after this period of inactivity to conserve resources. Only relevant when `wrapperLogUnclosedConnections` is enabled. | `30000` (30 seconds) | + +**Example:** +```bash +java -Daws.jdbc.cleanup.thread.ttl=60000 -jar myapp.jar +``` + ## Plugins The AWS Advanced JDBC Wrapper uses plugins to execute JDBC methods. You can think of a plugin as an extensible code module that adds extra logic around any JDBC method calls. The AWS Advanced JDBC Wrapper has a number of [built-in plugins](#list-of-available-plugins) available for use. diff --git a/wrapper/src/main/java/software/amazon/jdbc/util/LazyCleaner.java b/wrapper/src/main/java/software/amazon/jdbc/util/LazyCleaner.java new file mode 100644 index 000000000..682cc81d8 --- /dev/null +++ b/wrapper/src/main/java/software/amazon/jdbc/util/LazyCleaner.java @@ -0,0 +1,212 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* changes were made to move it into the software.amazon.jdbc.util package + * + * Copyright 2022 Juan Lopes + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package software.amazon.jdbc.util; + +import java.lang.ref.PhantomReference; +import java.lang.ref.ReferenceQueue; +import java.time.Duration; +import java.util.concurrent.ThreadFactory; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class LazyCleaner { + private static final Logger LOGGER = Logger.getLogger(LazyCleaner.class.getName()); + private static final LazyCleaner instance = + new LazyCleaner( + Duration.ofMillis(Long.getLong("aws.jdbc.cleanup.thread.ttl", 30000)), + "AWS-JDBC-Cleaner" + ); + + public interface Cleanable { + void clean() throws T; + } + + public interface CleaningAction { + void onClean(boolean leak) throws T; + } + + private final ReferenceQueue queue = new ReferenceQueue<>(); + private final long threadTtl; + private final ThreadFactory threadFactory; + private boolean threadRunning; + private int watchedCount; + private @Nullable Node first; + + public static LazyCleaner getInstance() { + return instance; + } + + public LazyCleaner(Duration threadTtl, final String threadName) { + this(threadTtl, runnable -> { + Thread thread = new Thread(runnable, threadName); + thread.setDaemon(true); + return thread; + }); + } + + private LazyCleaner(Duration threadTtl, ThreadFactory threadFactory) { + this.threadTtl = threadTtl.toMillis(); + this.threadFactory = threadFactory; + } + + public Cleanable register(Object obj, CleaningAction action) { + return add(new Node(obj, action)); + } + + public synchronized int getWatchedCount() { + return watchedCount; + } + + public synchronized boolean isThreadRunning() { + return threadRunning; + } + + private synchronized boolean checkEmpty() { + if (first == null) { + threadRunning = false; + return true; + } + return false; + } + + private synchronized Node add(Node node) { + if (first != null) { + node.next = first; + first.prev = node; + } + first = node; + watchedCount++; + + if (!threadRunning) { + threadRunning = startThread(); + } + return node; + } + + private boolean startThread() { + Thread thread = threadFactory.newThread(new Runnable() { + @Override + public void run() { + while (true) { + try { + Thread.currentThread().setContextClassLoader(null); + Thread.currentThread().setUncaughtExceptionHandler(null); + Node ref = (Node) queue.remove(threadTtl); + if (ref == null) { + if (checkEmpty()) { + break; + } + continue; + } + try { + ref.onClean(true); + } catch (Throwable e) { + if (e instanceof InterruptedException) { + LOGGER.log(Level.WARNING, "Unexpected interrupt while executing onClean", e); + throw e; + } + LOGGER.log(Level.WARNING, "Unexpected exception while executing onClean", e); + } + } catch (InterruptedException e) { + if (LazyCleaner.this.checkEmpty()) { + LOGGER.log( + Level.FINE, + "Cleanup queue is empty, and got interrupt, will terminate the cleanup thread" + ); + break; + } + LOGGER.log(Level.FINE, "Ignoring interrupt since the cleanup queue is non-empty"); + } catch (Throwable e) { + LOGGER.log(Level.WARNING, "Unexpected exception in cleaner thread main loop", e); + } + } + } + }); + if (thread != null) { + thread.start(); + return true; + } + LOGGER.log(Level.WARNING, "Unable to create cleanup thread"); + return false; + } + + private synchronized boolean remove(Node node) { + if (node.next == node) { + return false; + } + + if (first == node) { + first = node.next; + } + if (node.next != null) { + node.next.prev = node.prev; + } + if (node.prev != null) { + node.prev.next = node.next; + } + + node.next = node; + node.prev = node; + + watchedCount--; + return true; + } + + private class Node extends PhantomReference implements Cleanable, + CleaningAction { + private final @Nullable CleaningAction action; + private @Nullable Node prev; + private @Nullable Node next; + + Node(Object referent, CleaningAction action) { + super(referent, queue); + this.action = action; + } + + @Override + public void clean() throws T { + onClean(false); + } + + @Override + public void onClean(boolean leak) throws T { + if (!remove(this)) { + return; + } + if (action != null) { + action.onClean(leak); + } + } + } +} diff --git a/wrapper/src/main/java/software/amazon/jdbc/wrapper/ConnectionWrapper.java b/wrapper/src/main/java/software/amazon/jdbc/wrapper/ConnectionWrapper.java index c29f11c2a..2d6c0639e 100644 --- a/wrapper/src/main/java/software/amazon/jdbc/wrapper/ConnectionWrapper.java +++ b/wrapper/src/main/java/software/amazon/jdbc/wrapper/ConnectionWrapper.java @@ -47,6 +47,7 @@ import software.amazon.jdbc.cleanup.CanReleaseResources; import software.amazon.jdbc.profile.ConfigurationProfile; import software.amazon.jdbc.util.FullServicesContainer; +import software.amazon.jdbc.util.LazyCleaner; import software.amazon.jdbc.util.Messages; import software.amazon.jdbc.util.SqlState; import software.amazon.jdbc.util.WrapperUtils; @@ -63,6 +64,7 @@ public class ConnectionWrapper implements Connection, CanReleaseResources { protected final String originalUrl; protected @Nullable ConfigurationProfile configurationProfile; protected @Nullable Throwable openConnectionStacktrace; + private LazyCleaner.@NonNull Cleanable cleanable; public ConnectionWrapper( @NonNull final FullServicesContainer servicesContainer, @@ -71,8 +73,10 @@ public ConnectionWrapper( @NonNull final String targetDriverProtocol, @Nullable final ConfigurationProfile configurationProfile) throws SQLException { + this.pluginManager = servicesContainer.getConnectionPluginManager(); this.pluginService = servicesContainer.getPluginService(); + this.cleanable = LazyCleaner.getInstance().register(this, new CleanupAction(openConnectionStacktrace, pluginManager, pluginService)); this.hostListProviderService = servicesContainer.getHostListProviderService(); this.pluginManagerService = servicesContainer.getPluginManagerService(); this.originalUrl = url; @@ -125,9 +129,10 @@ protected void init(final Properties props) throws SQLException { } public void releaseResources() { - this.pluginManager.releaseResources(); - if (this.pluginService instanceof CanReleaseResources) { - ((CanReleaseResources) this.pluginService).releaseResources(); + try { + this.cleanable.clean(); + } catch (Throwable e) { + // ignore } } @@ -187,6 +192,11 @@ public void close() throws SQLException { this.pluginService.getSessionStateService().complete(); this.pluginService.getSessionStateService().reset(); } + try { + this.cleanable.clean(); + } catch (Throwable e) { + // Ignore cleanup exceptions + } this.openConnectionStacktrace = null; this.pluginManagerService.setInTransaction(false); }); @@ -200,10 +210,14 @@ public void close() throws SQLException { this.pluginService.getSessionStateService().complete(); this.pluginService.getSessionStateService().reset(); } + try { + this.cleanable.clean(); + } catch (Throwable e) { + // Ignore cleanup exceptions + } this.openConnectionStacktrace = null; this.pluginManagerService.setInTransaction(false); } - this.releaseResources(); } @Override @@ -1138,27 +1152,38 @@ public String toString() { return super.toString() + " - " + this.pluginService.getCurrentConnection(); } - @SuppressWarnings("checkstyle:NoFinalizer") - protected void finalize() throws Throwable { + public Connection getCurrentConnection() { + return this.pluginService.getCurrentConnection(); + } - try { - if (this.openConnectionStacktrace != null) { + private static class CleanupAction implements LazyCleaner.CleaningAction { + private final Throwable openConnectionStacktrace; + private final PluginService pluginService; + private final ConnectionPluginManager pluginManager; + + CleanupAction(Throwable openConnectionStacktrace, ConnectionPluginManager pluginManager, PluginService pluginService) { + this.openConnectionStacktrace = openConnectionStacktrace; + this.pluginService = pluginService; + this.pluginManager = pluginManager; + } + + @Override + public void onClean(boolean leak) { + if (leak) { LOGGER.log( Level.WARNING, - this.openConnectionStacktrace, - () -> Messages.get( - "ConnectionWrapper.finalizingUnclosedConnection")); - this.openConnectionStacktrace = null; + openConnectionStacktrace, + () -> Messages.get("ConnectionWrapper.finalizingUnclosedConnection")); } - this.releaseResources(); - - } finally { - super.finalize(); + try { + if (pluginService instanceof CanReleaseResources) { + ((CanReleaseResources) pluginService).releaseResources(); + } + pluginManager.releaseResources(); + } catch (Exception e) { + // Ignore exceptions during cleanup + } } } - - public Connection getCurrentConnection() { - return this.pluginService.getCurrentConnection(); - } } diff --git a/wrapper/src/test/java/software/amazon/jdbc/util/Await.java b/wrapper/src/test/java/software/amazon/jdbc/util/Await.java new file mode 100644 index 000000000..8b8f1b469 --- /dev/null +++ b/wrapper/src/test/java/software/amazon/jdbc/util/Await.java @@ -0,0 +1,35 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package software.amazon.jdbc.util; + +import java.time.Duration; + +public class Await { + public static void until(String message, Duration timeout, Condition condition) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeout.toMillis(); + while (!condition.get()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Condition not met within " + timeout + ": " + message); + } + Thread.sleep(100); + } + } + + public interface Condition { + boolean get(); + } +} diff --git a/wrapper/src/test/java/software/amazon/jdbc/util/LazyCleanerTest.java b/wrapper/src/test/java/software/amazon/jdbc/util/LazyCleanerTest.java new file mode 100644 index 000000000..f20659b0f --- /dev/null +++ b/wrapper/src/test/java/software/amazon/jdbc/util/LazyCleanerTest.java @@ -0,0 +1,158 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* changes were made to move it into the software.amazon.jdbc.util package + * + * Copyright 2022 Juan Lopes + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package software.amazon.jdbc.util; + +import static java.time.Duration.ofSeconds; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +public class LazyCleanerTest { + @Test + void phantomCleaner() throws InterruptedException { + List list = new ArrayList<>(Arrays.asList( + new Object(), new Object(), new Object())); + + LazyCleaner t = new LazyCleaner(ofSeconds(5), "Cleaner"); + + String[] collected = new String[list.size()]; + List> cleaners = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + final int ii = i; + cleaners.add( + t.register( + list.get(i), + leak -> { + collected[ii] = leak ? "LEAK" : "NO LEAK"; + if (ii == 0) { + throw new RuntimeException( + "Exception from cleanup action to verify if the cleaner thread would survive" + ); + } + } + ) + ); + } + assertEquals( + list.size(), + t.getWatchedCount(), + "All objects are strongly-reachable, so getWatchedCount should reflect it" + ); + + assertTrue(t.isThreadRunning(), + "cleanup thread should be running, and it should wait for the leaks"); + + cleaners.get(1).clean(); + + assertEquals( + list.size() - 1, + t.getWatchedCount(), + "One object has been released properly, so getWatchedCount should reflect it" + ); + + list.set(0, null); + System.gc(); + System.gc(); + + Await.until( + "One object was released, and another one has leaked, so getWatchedCount should reflect it", + ofSeconds(5), + () -> t.getWatchedCount() == list.size() - 2 + ); + + list.clear(); + System.gc(); + System.gc(); + + Await.until( + "The cleanup thread should detect leaks and terminate within 5-10 seconds after GC", + ofSeconds(10), + () -> !t.isThreadRunning() + ); + + assertEquals( + Arrays.asList("LEAK", "NO LEAK", "LEAK").toString(), + Arrays.asList(collected).toString(), + "Second object has been released properly, so it should be reported as NO LEAK" + ); + } + + @Test + void getThread() throws InterruptedException { + String threadName = UUID.randomUUID().toString(); + LazyCleaner t = new LazyCleaner(ofSeconds(5), threadName); + List list = new ArrayList<>(); + list.add(new Object()); + LazyCleaner.Cleanable cleanable = + t.register( + list.get(0), + leak -> { + throw new IllegalStateException("test exception from CleaningAction"); + } + ); + assertTrue(t.isThreadRunning(), + "cleanup thread should be running, and it should wait for the leaks"); + Thread thread = getThreadByName(threadName); + thread.interrupt(); + Await.until( + "The cleanup thread should ignore the interrupt since there's one object to monitor", + ofSeconds(10), + () -> !thread.isInterrupted() + ); + assertThrows( + IllegalStateException.class, + cleanable::clean, + "Exception from cleanable.clean() should be rethrown" + ); + thread.interrupt(); + Await.until( + "The cleanup thread should exit shortly after interrupt as there's no leaks to monitor", + ofSeconds(1), + () -> !t.isThreadRunning() + ); + } + + public static Thread getThreadByName(String threadName) { + for (Thread t : Thread.getAllStackTraces().keySet()) { + if (t.getName().equals(threadName)) { + return t; + } + } + throw new IllegalStateException("Cleanup thread " + threadName + " not found"); + } +}