Skip to content

Commit be25484

Browse files
committed
Clean Up + Tests
1 parent c8f2f3b commit be25484

5 files changed

Lines changed: 240 additions & 1 deletion

File tree

src/main/java/com/smushytaco/postgres/embedded/EmbeddedPostgres.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import java.util.Map.Entry;
4343
import java.util.concurrent.Phaser;
4444
import java.util.concurrent.atomic.AtomicBoolean;
45+
import java.util.concurrent.atomic.AtomicReference;
4546
import java.util.concurrent.locks.Lock;
4647
import java.util.concurrent.locks.ReentrantLock;
4748
import java.util.function.Consumer;
@@ -92,6 +93,8 @@ public class EmbeddedPostgres implements Closeable {
9293
private final ProcessBuilder.Redirect errorRedirector;
9394
private final ProcessBuilder.Redirect outputRedirector;
9495

96+
private final AtomicReference<Thread> shutdownHook = new AtomicReference<>();
97+
9598
@SuppressWarnings("unused")
9699
EmbeddedPostgres(final Path parentDirectory, final Path dataDirectory, final boolean cleanDataDirectory, final boolean registerShutdownHook,
97100
final Map<String, String> postgresConfig, final Map<String, String> localeConfig, final int port, final Map<String, String> connectConfig,
@@ -285,7 +288,11 @@ private void startPostmaster() {
285288
"-w", "start"
286289
));
287290

288-
if (registerShutdownHook) Runtime.getRuntime().addShutdownHook(newCloserThread());
291+
if (registerShutdownHook) {
292+
final Thread hook = newCloserThread();
293+
Runtime.getRuntime().addShutdownHook(hook);
294+
shutdownHook.set(hook);
295+
}
289296

290297
system(pgCtl, args, pgStartupWait);
291298

@@ -346,6 +353,7 @@ private static void deleteDirectoryRecursively(final Path dir) throws IOExceptio
346353
@Override
347354
public void close() throws IOException {
348355
if (closed.getAndSet(true)) return;
356+
unregisterShutdownHook();
349357
final Instant start = Instant.now();
350358
try {
351359
pgCtl(dataDirectory, "stop");
@@ -375,6 +383,19 @@ public void close() throws IOException {
375383
}
376384
}
377385

386+
private void unregisterShutdownHook() {
387+
final Thread currentShutdownHook = shutdownHook.getAndSet(null);
388+
if (currentShutdownHook == null || Thread.currentThread() == currentShutdownHook) return;
389+
390+
try {
391+
Runtime.getRuntime().removeShutdownHook(currentShutdownHook);
392+
} catch (final IllegalStateException e) {
393+
LOG.trace("JVM is already shutting down; could not remove shutdown hook {}", currentShutdownHook.getName(), e);
394+
} catch (final Exception e) {
395+
LOG.warn("Failed to remove shutdown hook {}", currentShutdownHook.getName(), e);
396+
}
397+
}
398+
378399
@SuppressWarnings("SameParameterValue")
379400
private void pgCtl(final Path dir, final String action) {
380401
final List<String> args = new ArrayList<>(Arrays.asList(

src/main/java/com/smushytaco/postgres/embedded/PreparedDbProvider.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ public static void closeAll() throws IOException {
240240
}
241241

242242
private void ensureOpen() {
243+
if (sharedCluster.closed.get()) closed.set(true);
243244
if (closed.get()) throw new IllegalStateException("PreparedDbProvider has been closed");
244245
}
245246

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2025 Nikan Radan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.smushytaco.postgres.embedded;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import java.io.IOException;
22+
import java.lang.reflect.Field;
23+
import java.util.concurrent.atomic.AtomicReference;
24+
25+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
26+
import static org.junit.jupiter.api.Assertions.assertNotNull;
27+
import static org.junit.jupiter.api.Assertions.assertNull;
28+
29+
class EmbeddedPostgresShutdownHookTest {
30+
@Test
31+
void testExplicitCloseUnregistersShutdownHookAndRemainsIdempotent() throws IOException, NoSuchFieldException, IllegalAccessException {
32+
try (final EmbeddedPostgres pg = EmbeddedPostgres.builder()
33+
.setRegisterShutdownHook(true)
34+
.start()) {
35+
final AtomicReference<?> shutdownHook = getShutdownHook(pg);
36+
assertNotNull(shutdownHook.get());
37+
38+
pg.close();
39+
assertNull(shutdownHook.get());
40+
assertDoesNotThrow(pg::close);
41+
}
42+
}
43+
44+
@SuppressWarnings("unchecked")
45+
private static AtomicReference<Thread> getShutdownHook(final EmbeddedPostgres pg) throws NoSuchFieldException, IllegalAccessException {
46+
final Field shutdownHookField = EmbeddedPostgres.class.getDeclaredField("shutdownHook");
47+
shutdownHookField.setAccessible(true);
48+
return (AtomicReference<Thread>) shutdownHookField.get(pg);
49+
}
50+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2025 Nikan Radan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.smushytaco.postgres.embedded;
18+
19+
import org.junit.jupiter.api.AfterEach;
20+
import org.junit.jupiter.api.Test;
21+
22+
import java.io.IOException;
23+
import java.net.ServerSocket;
24+
import java.sql.Connection;
25+
import java.sql.ResultSet;
26+
import java.sql.SQLException;
27+
import java.sql.Statement;
28+
import java.util.List;
29+
30+
import static org.junit.jupiter.api.Assertions.*;
31+
32+
class PreparedDbProviderFailureCleanupTest {
33+
@AfterEach
34+
void tearDown() throws IOException {
35+
PreparedDbProvider.closeAll();
36+
}
37+
38+
@SuppressWarnings({"SqlNoDataSourceInspection", "java:S5778"})
39+
@Test
40+
void testFailedPreparationDoesNotLeakStartedPostgresInstance() throws IOException, SQLException {
41+
final int port = detectFreePort();
42+
43+
@SuppressWarnings("resource") final RuntimeException exception = assertThrows(RuntimeException.class, () -> PreparedDbProvider.forPreparer(_ -> {
44+
throw new SQLException("boom");
45+
}, List.of(builder -> {
46+
builder.setPort(port);
47+
builder.setRegisterShutdownHook(false);
48+
}), ClusterRetentionPolicy.CLOSE_ON_LAST_RELEASE));
49+
assertInstanceOf(SQLException.class, exception.getCause());
50+
51+
try (final EmbeddedPostgres pg = EmbeddedPostgres.builder()
52+
.setPort(port)
53+
.setRegisterShutdownHook(false)
54+
.start();
55+
final Connection connection = pg.getPostgresDatabase().getConnection();
56+
final Statement statement = connection.createStatement();
57+
final ResultSet resultSet = statement.executeQuery("SELECT 1")) {
58+
assertTrue(resultSet.next());
59+
assertEquals(1, resultSet.getInt(1));
60+
}
61+
}
62+
63+
private static int detectFreePort() throws IOException {
64+
try (final ServerSocket socket = new ServerSocket(0)) {
65+
socket.setReuseAddress(true);
66+
return socket.getLocalPort();
67+
}
68+
}
69+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2025 Nikan Radan
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.smushytaco.postgres.embedded;
18+
19+
import org.junit.jupiter.api.AfterEach;
20+
import org.junit.jupiter.api.Test;
21+
22+
import javax.sql.DataSource;
23+
import java.io.IOException;
24+
import java.sql.SQLException;
25+
import java.util.concurrent.atomic.AtomicInteger;
26+
27+
import static org.junit.jupiter.api.Assertions.assertEquals;
28+
import static org.junit.jupiter.api.Assertions.assertThrows;
29+
30+
class PreparedDbProviderLifecycleTest {
31+
@AfterEach
32+
void tearDown() throws IOException {
33+
PreparedDbProvider.closeAll();
34+
}
35+
36+
@Test
37+
void testKeepUntilCloseAllRetainsClusterUntilExplicitGlobalClose() throws SQLException, IOException {
38+
final CountingPreparer preparer = new CountingPreparer();
39+
40+
final PreparedDbProvider provider1 = PreparedDbProvider.forPreparer(preparer, ClusterRetentionPolicy.KEEP_UNTIL_CLOSE_ALL);
41+
provider1.createNewDatabase();
42+
assertEquals(1, preparer.getInvocationCount());
43+
44+
provider1.close();
45+
assertThrows(IllegalStateException.class, provider1::createNewDatabase);
46+
47+
final PreparedDbProvider provider2 = PreparedDbProvider.forPreparer(preparer, ClusterRetentionPolicy.KEEP_UNTIL_CLOSE_ALL);
48+
provider2.createNewDatabase();
49+
assertEquals(1, preparer.getInvocationCount());
50+
51+
PreparedDbProvider.closeAll();
52+
assertThrows(IllegalStateException.class, provider2::createNewDatabase);
53+
54+
final PreparedDbProvider provider3 = PreparedDbProvider.forPreparer(preparer, ClusterRetentionPolicy.KEEP_UNTIL_CLOSE_ALL);
55+
provider3.createNewDatabase();
56+
assertEquals(2, preparer.getInvocationCount());
57+
provider3.close();
58+
}
59+
60+
@Test
61+
void testCloseOnLastReleaseRecreatesClusterAfterFinalClose() throws SQLException, IOException {
62+
final CountingPreparer preparer = new CountingPreparer();
63+
64+
final PreparedDbProvider provider1 = PreparedDbProvider.forPreparer(preparer, ClusterRetentionPolicy.CLOSE_ON_LAST_RELEASE);
65+
final PreparedDbProvider provider2 = PreparedDbProvider.forPreparer(preparer, ClusterRetentionPolicy.CLOSE_ON_LAST_RELEASE);
66+
67+
provider1.createNewDatabase();
68+
provider2.createNewDatabase();
69+
assertEquals(1, preparer.getInvocationCount());
70+
71+
provider1.close();
72+
assertThrows(IllegalStateException.class, provider1::createNewDatabase);
73+
74+
provider2.createNewDatabase();
75+
assertEquals(1, preparer.getInvocationCount());
76+
77+
provider2.close();
78+
assertThrows(IllegalStateException.class, provider2::createNewDatabase);
79+
80+
final PreparedDbProvider provider3 = PreparedDbProvider.forPreparer(preparer, ClusterRetentionPolicy.CLOSE_ON_LAST_RELEASE);
81+
provider3.createNewDatabase();
82+
assertEquals(2, preparer.getInvocationCount());
83+
provider3.close();
84+
}
85+
86+
private static final class CountingPreparer implements DatabasePreparer {
87+
private final AtomicInteger invocationCount = new AtomicInteger();
88+
89+
@Override
90+
public void prepare(final DataSource ds) {
91+
invocationCount.incrementAndGet();
92+
}
93+
94+
private int getInvocationCount() {
95+
return invocationCount.get();
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)