diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialConformanceDriver.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialConformanceDriver.java index a5baa2d..ff66998 100644 --- a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialConformanceDriver.java +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialConformanceDriver.java @@ -8,12 +8,16 @@ import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.sql.CallableStatement; import java.sql.Connection; +import java.sql.DatabaseMetaData; import java.sql.Driver; import java.sql.DriverManager; import java.sql.DriverPropertyInfo; +import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; import java.util.Properties; import java.util.UUID; import java.util.concurrent.Executor; @@ -27,14 +31,22 @@ * is resolved against the process working directory, normalized, and hashed to a namespace that is * retained until the suite shuts down, so closing every connection and reopening the same path * observes the same data. A relative path and the equivalent absolute path therefore address the - * same namespace. Query strings are ignored when deriving the namespace, so URL pragmas have no - * effect on the conformance server. + * same namespace. The query string is part of the namespace identity, so two URLs with different + * options address different namespaces; the options themselves are not applied to the server. + * + *

SQLite URI filenames such as {@code file::memory:?cache=shared} and {@code ?mode=memory} are + * in-memory databases, not files. They map to a fresh namespace per connection and are never + * retained across tests. + * + *

The returned connection is a proxy. Statements and metadata obtained from it return the same + * proxy from {@code getConnection()}, so closing through any JDBC accessor releases the namespace. */ public final class XerialConformanceDriver implements Driver { private static final String PREFIX = "jdbc:sqlite:"; private static final String HTTP_PREFIX = PREFIX + "http://"; private static final String HTTPS_PREFIX = PREFIX + "https://"; + private static final String FILE_SCHEME = "file:"; private final LibsqlDriver delegate = new LibsqlDriver(); @@ -51,10 +63,13 @@ public Connection connect(String url, Properties info) throws SQLException { if (!acceptsURL(url)) { return null; } - Target target = rewrite(url); + Target target = plan(url); Connection connection; try { - connection = delegate.connect(target.url(), info); + connection = + delegate.connect( + XerialTestServer.jdbcUrl(target.namespace(), target.retained()), + info); } catch (SQLException e) { releaseAfterConnectFailure(target, e); throw e; @@ -64,12 +79,15 @@ public Connection connect(String url, Properties info) throws SQLException { return null; } try { - return (Connection) - Proxy.newProxyInstance( - XerialConformanceDriver.class.getClassLoader(), - new Class[] {Connection.class}, - new NamespaceClosingHandler( - connection, target.namespace(), releaser(target))); + Connection proxy = + (Connection) + Proxy.newProxyInstance( + XerialConformanceDriver.class.getClassLoader(), + new Class[] {Connection.class}, + new NamespaceClosingHandler( + connection, target.namespace(), releaser(target))); + XerialTestServer.registerConnection(target.namespace(), connection); + return proxy; } catch (RuntimeException e) { try { release(target); @@ -131,20 +149,28 @@ private static Consumer releaser(Target target) { : XerialTestServer::releaseNamespace; } - private static Target rewrite(String url) { + static Target plan(String url) { String target = url.substring(PREFIX.length()); - String path = stripQuery(target); - if (path.isEmpty() || ":memory:".equals(path)) { + boolean fileUri = target.startsWith(FILE_SCHEME); + String withoutScheme = fileUri ? target.substring(FILE_SCHEME.length()) : target; + String path = stripQuery(withoutScheme); + String query = queryOf(withoutScheme); + boolean memory = path.isEmpty() || ":memory:".equals(path); + if (!memory && fileUri) { + memory = hasMemoryMode(query); + } + if (memory) { String namespace = "mem_" + UUID.randomUUID().toString().replace("-", ""); - return new Target(XerialTestServer.jdbcUrl(namespace, false), namespace, false); + return new Target(namespace, false); } String normalized = Path.of(path).toAbsolutePath().normalize().toString(); + String identity = query.isEmpty() ? normalized : normalized + "?" + query; String namespace = "file_" - + UUID.nameUUIDFromBytes(normalized.getBytes(StandardCharsets.UTF_8)) + + UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)) .toString() .replace("-", ""); - return new Target(XerialTestServer.jdbcUrl(namespace, true), namespace, true); + return new Target(namespace, true); } private static String stripQuery(String target) { @@ -152,13 +178,31 @@ private static String stripQuery(String target) { return query < 0 ? target : target.substring(0, query); } - private record Target(String url, String namespace, boolean retained) {} + private static String queryOf(String target) { + int query = target.indexOf('?'); + return query < 0 ? "" : target.substring(query + 1); + } + + private static boolean hasMemoryMode(String query) { + for (String parameter : query.split("&")) { + int equals = parameter.indexOf('='); + String name = equals < 0 ? parameter : parameter.substring(0, equals); + String value = equals < 0 ? "" : parameter.substring(equals + 1); + if ("mode".equals(name) && "memory".equalsIgnoreCase(value)) { + return true; + } + } + return false; + } + + record Target(String namespace, boolean retained) {} static final class NamespaceClosingHandler implements InvocationHandler { private final Connection connection; private final String namespace; private final Consumer release; + private DatabaseMetaData metadata; private boolean closed; NamespaceClosingHandler(Connection connection, String namespace) { @@ -174,20 +218,59 @@ static final class NamespaceClosingHandler implements InvocationHandler { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { - if ("close".equals(method.getName()) && method.getParameterCount() == 0) { + String name = method.getName(); + if ("close".equals(name) && method.getParameterCount() == 0) { close(); return null; } - if ("abort".equals(method.getName()) && method.getParameterCount() == 1) { + if ("abort".equals(name) && method.getParameterCount() == 1) { abort((Executor) args[0]); return null; } - if ("unwrap".equals(method.getName()) && method.getParameterCount() == 1) { - Class iface = (Class) args[0]; - if (iface != null && iface.isInstance(proxy)) { - return proxy; + if ("isClosed".equals(name) && method.getParameterCount() == 0) { + return closed || connection.isClosed(); + } + if ("equals".equals(name) && method.getParameterCount() == 1) { + return proxy == args[0]; + } + if ("hashCode".equals(name) && method.getParameterCount() == 0) { + return System.identityHashCode(proxy); + } + if ("getMetaData".equals(name) && method.getParameterCount() == 0) { + if (closed) { + throw new SQLException("Connection is closed"); } + return metadata((Connection) proxy); } + if (isWrapper(method) + && args != null + && args.length == 1 + && args[0] instanceof Class type + && type.isInstance(proxy)) { + return name.equals("unwrap") ? proxy : true; + } + if (isStatementFactory(name)) { + Object result = invokeTarget(method, args); + return result instanceof Statement statement + ? wrapStatement(statement, (Connection) proxy) + : result; + } + return invokeTarget(method, args); + } + + private DatabaseMetaData metadata(Connection proxy) throws SQLException { + if (metadata == null) { + metadata = + (DatabaseMetaData) + Proxy.newProxyInstance( + XerialConformanceDriver.class.getClassLoader(), + new Class[] {DatabaseMetaData.class}, + new MetaDataHandler(connection.getMetaData(), proxy)); + } + return metadata; + } + + private Object invokeTarget(Method method, Object[] args) throws Throwable { try { return method.invoke(connection, args); } catch (InvocationTargetException e) { @@ -230,6 +313,7 @@ private synchronized void abort(Executor executor) throws SQLException { } private void releaseNamespace() { + XerialTestServer.unregisterConnection(connection); release.accept(namespace); } @@ -241,4 +325,104 @@ private void releaseWithSuppressed(Throwable failure) { } } } + + static final class StatementHandler implements InvocationHandler { + + private final Statement statement; + private final Connection connection; + + StatementHandler(Statement statement, Connection connection) { + this.statement = statement; + this.connection = connection; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String name = method.getName(); + if ("getConnection".equals(name) && method.getParameterCount() == 0) { + return connection; + } + if ("equals".equals(name) && method.getParameterCount() == 1) { + return proxy == args[0]; + } + if ("hashCode".equals(name) && method.getParameterCount() == 0) { + return System.identityHashCode(proxy); + } + if (isWrapper(method) + && args != null + && args.length == 1 + && args[0] instanceof Class type + && type.isInstance(proxy)) { + return name.equals("unwrap") ? proxy : true; + } + try { + return method.invoke(statement, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + } + + static final class MetaDataHandler implements InvocationHandler { + + private final DatabaseMetaData metadata; + private final Connection connection; + + MetaDataHandler(DatabaseMetaData metadata, Connection connection) { + this.metadata = metadata; + this.connection = connection; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String name = method.getName(); + if ("getConnection".equals(name) && method.getParameterCount() == 0) { + return connection; + } + if ("equals".equals(name) && method.getParameterCount() == 1) { + return proxy == args[0]; + } + if ("hashCode".equals(name) && method.getParameterCount() == 0) { + return System.identityHashCode(proxy); + } + if (isWrapper(method) + && args != null + && args.length == 1 + && args[0] instanceof Class type + && type.isInstance(proxy)) { + return name.equals("unwrap") ? proxy : true; + } + try { + return method.invoke(metadata, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + } + + private static Statement wrapStatement(Statement statement, Connection connection) { + Class type; + if (statement instanceof CallableStatement) { + type = CallableStatement.class; + } else if (statement instanceof PreparedStatement) { + type = PreparedStatement.class; + } else { + type = Statement.class; + } + return (Statement) + Proxy.newProxyInstance( + XerialConformanceDriver.class.getClassLoader(), + new Class[] {type}, + new StatementHandler(statement, connection)); + } + + private static boolean isStatementFactory(String name) { + return "createStatement".equals(name) + || "prepareStatement".equals(name) + || "prepareCall".equals(name); + } + + private static boolean isWrapper(Method method) { + return "unwrap".equals(method.getName()) || "isWrapperFor".equals(method.getName()); + } } diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java new file mode 100644 index 0000000..5714051 --- /dev/null +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialHarnessTest.java @@ -0,0 +1,283 @@ +package com.github.magnusp.libsql.conformance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Proxy; +import java.net.http.HttpRequest; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Exercises the conformance harness itself, without a running sqld container where possible. + * + *

The fake connection stands in for {@code LibsqlConnection}. It exposes a statement and + * metadata whose {@code getConnection()} returns the raw connection, which is exactly the shape + * that leaked the namespace before the driver wrapped those accessors. + */ +class XerialHarnessTest { + + private static final String ADMIN_URL = "http://admin.test"; + + @TempDir Path tempDir; + + @Test + void statementAndMetadataReturnTheWrappedConnection() throws SQLException { + FakeConnection fake = new FakeConnection(); + Connection wrapped = wrap(fake.connection, "ns"); + + assertThat(wrapped.createStatement().getConnection()).isSameAs(wrapped); + assertThat(wrapped.getMetaData().getConnection()).isSameAs(wrapped); + assertThat(wrapped.unwrap(Connection.class)).isSameAs(wrapped); + } + + @Test + void closingThroughAStatementConnectionReleasesTheNamespaceOnce() throws SQLException { + FakeConnection fake = new FakeConnection(); + AtomicInteger releases = new AtomicInteger(); + Connection wrapped = wrap(fake.connection, "ns", namespace -> releases.incrementAndGet()); + + wrapped.createStatement().getConnection().close(); + wrapped.close(); + + assertThat(releases).hasValue(1); + } + + @Test + void fileMemoryUriIsNotRetained() { + XerialConformanceDriver.Target memory = + XerialConformanceDriver.plan("jdbc:sqlite:file::memory:?cache=shared"); + XerialConformanceDriver.Target memoryMode = + XerialConformanceDriver.plan("jdbc:sqlite:file:/tmp/ignored.db?mode=memory"); + + assertThat(memory.retained()).isFalse(); + assertThat(memoryMode.retained()).isFalse(); + } + + @Test + void queryStringIsPartOfTheFileNamespace() { + XerialConformanceDriver.Target readOnly = + XerialConformanceDriver.plan("jdbc:sqlite:/tmp/example.db?mode=ro"); + XerialConformanceDriver.Target readWrite = + XerialConformanceDriver.plan("jdbc:sqlite:/tmp/example.db?mode=rw"); + XerialConformanceDriver.Target repeated = + XerialConformanceDriver.plan("jdbc:sqlite:/tmp/example.db?mode=ro"); + + assertThat(readOnly.retained()).isTrue(); + assertThat(readOnly.namespace()).isNotEqualTo(readWrite.namespace()); + assertThat(repeated.namespace()).isEqualTo(readOnly.namespace()); + } + + @Test + void sharedMemoryFileUriDoesNotKeepDataAcrossConnections() throws SQLException { + String url = "jdbc:sqlite:file::memory:?cache=shared"; + try (Connection connection = DriverManager.getConnection(url); + Statement statement = connection.createStatement()) { + statement.execute("create table isolated_shared (id integer)"); + statement.execute("insert into isolated_shared values (1)"); + } + + assertThatThrownBy( + () -> { + try (Connection connection = DriverManager.getConnection(url); + Statement statement = connection.createStatement(); + ResultSet resultSet = + statement.executeQuery( + "select count(*) from isolated_shared")) { + assertThat(resultSet.next()).isTrue(); + } + }) + .isInstanceOf(SQLException.class) + .hasMessageContaining("no such table"); + } + + @Test + void fileNamespacesWithDifferentQueryStringsAreIsolated() throws SQLException { + Path database = tempDir.resolve("query.db").toAbsolutePath().normalize(); + String writeUrl = "jdbc:sqlite:" + database + "?mode=rw"; + String readUrl = "jdbc:sqlite:" + database + "?mode=ro"; + + try (Connection connection = DriverManager.getConnection(writeUrl); + Statement statement = connection.createStatement()) { + statement.execute("create table isolated_query (id integer)"); + statement.execute("insert into isolated_query values (1)"); + } + + assertThatThrownBy( + () -> { + try (Connection connection = DriverManager.getConnection(readUrl); + Statement statement = connection.createStatement(); + ResultSet resultSet = + statement.executeQuery( + "select count(*) from isolated_query")) { + assertThat(resultSet.next()).isTrue(); + } + }) + .isInstanceOf(SQLException.class) + .hasMessageContaining("no such table"); + } + + @Test + void stopFailsWhenANamespaceStillHasAnOpenConnection() { + FakeAdmin admin = new FakeAdmin(); + String namespace = namespace(); + XerialTestServer.ensureNamespace(namespace, ADMIN_URL, admin); + try { + assertThatThrownBy(XerialTestServer::stopForTest) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(namespace) + .hasMessageContaining("open"); + } finally { + XerialTestServer.resetNamespaceForTest(namespace); + } + } + + @Test + void stopReportsASwallowedCleanupFailure() { + FakeAdmin admin = new FakeAdmin(); + String namespace = namespace(); + XerialTestServer.ensureNamespace(namespace, ADMIN_URL, admin); + admin.deleteStatus.set(500); + try { + assertThatThrownBy( + () -> + XerialTestServer.releaseNamespace( + namespace, ADMIN_URL, admin)) + .isInstanceOf(IllegalStateException.class); + + assertThatThrownBy(XerialTestServer::stopForTest) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cleanup") + .hasMessageContaining(namespace); + } finally { + XerialTestServer.resetNamespaceForTest(namespace); + } + } + + private static Connection wrap(Connection connection, String namespace) { + return wrap(connection, namespace, ignored -> {}); + } + + private static Connection wrap( + Connection connection, + String namespace, + java.util.function.Consumer release) { + return (Connection) + Proxy.newProxyInstance( + XerialConformanceDriver.class.getClassLoader(), + new Class[] {Connection.class}, + new XerialConformanceDriver.NamespaceClosingHandler( + connection, namespace, release)); + } + + private static String namespace() { + return "harness_" + UUID.randomUUID().toString().replace("-", ""); + } + + private static final class FakeConnection { + + private final Statement statement = statement(); + private final DatabaseMetaData metadata = metadata(); + private final Connection connection = connection(); + + private Connection connection() { + return (Connection) + Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] {Connection.class}, + (proxy, method, args) -> + switch (method.getName()) { + case "createStatement" -> statement; + case "getMetaData" -> metadata; + case "isClosed" -> false; + case "close" -> null; + case "equals" -> proxy == args[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "fake connection"; + default -> defaultValue(method.getReturnType()); + }); + } + + private Statement statement() { + return (Statement) + Proxy.newProxyInstance( + Statement.class.getClassLoader(), + new Class[] {Statement.class}, + (proxy, method, args) -> + switch (method.getName()) { + case "getConnection" -> connection; + case "isClosed" -> false; + case "close" -> null; + case "equals" -> proxy == args[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "fake statement"; + default -> defaultValue(method.getReturnType()); + }); + } + + private DatabaseMetaData metadata() { + return (DatabaseMetaData) + Proxy.newProxyInstance( + DatabaseMetaData.class.getClassLoader(), + new Class[] {DatabaseMetaData.class}, + (proxy, method, args) -> + switch (method.getName()) { + case "getConnection" -> connection; + case "equals" -> proxy == args[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> "fake metadata"; + default -> defaultValue(method.getReturnType()); + }); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0f; + } + return 0d; + } + } + + private static final class FakeAdmin implements XerialTestServer.AdminClient { + + private final AtomicInteger deleteStatus = new AtomicInteger(200); + + @Override + public XerialTestServer.AdminResponse send(HttpRequest request) { + if ("DELETE".equals(request.method())) { + return new XerialTestServer.AdminResponse(deleteStatus.get(), "status"); + } + return new XerialTestServer.AdminResponse(200, "{}"); + } + } +} diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServer.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServer.java index d06424e..982828d 100644 --- a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServer.java +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServer.java @@ -8,7 +8,14 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.sql.Connection; +import java.sql.SQLException; import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -36,6 +43,7 @@ public final class XerialTestServer { private static final Set RETAINED_NAMESPACES = ConcurrentHashMap.newKeySet(); private static final Map FAILED_NAMESPACES = new ConcurrentHashMap<>(); + private static final Map> CONNECTIONS = new ConcurrentHashMap<>(); private static GenericContainer container; private static String httpBaseUrl; @@ -88,45 +96,168 @@ public static synchronized void stop() { if (container == null) { return; } - RuntimeException failure = reapRetainedNamespaces(); + RuntimeException failure = openConnectionFailure(); + failure = merge(failure, failedNamespacesFailure()); + failure = merge(failure, reapRetainedNamespaces()); try { container.stop(); } catch (RuntimeException stopFailure) { - if (failure == null) { - failure = stopFailure; - } else { - failure.addSuppressed(stopFailure); - } + failure = merge(failure, stopFailure); } finally { - container = null; - httpBaseUrl = null; - adminBaseUrl = null; - NAMESPACES.clear(); - RETAINED_NAMESPACES.clear(); - FAILED_NAMESPACES.clear(); + resetState(); } if (failure != null) { throw failure; } } + static synchronized void stopForTest() { + RuntimeException failure = openConnectionFailure(); + failure = merge(failure, failedNamespacesFailure()); + if (failure != null) { + throw failure; + } + } + + static synchronized void resetNamespaceForTest(String namespace) { + NAMESPACES.remove(namespace); + RETAINED_NAMESPACES.remove(namespace); + FAILED_NAMESPACES.remove(namespace); + CONNECTIONS.remove(namespace); + } + + private static RuntimeException merge(RuntimeException failure, RuntimeException next) { + if (next == null) { + return failure; + } + if (failure == null) { + return next; + } + failure.addSuppressed(next); + return failure; + } + + private static void resetState() { + container = null; + httpBaseUrl = null; + adminBaseUrl = null; + resetNamespaces(); + } + + private static void resetNamespaces() { + NAMESPACES.clear(); + RETAINED_NAMESPACES.clear(); + FAILED_NAMESPACES.clear(); + CONNECTIONS.clear(); + } + + private static RuntimeException openConnectionFailure() { + List open = new ArrayList<>(); + RuntimeException cleanupFailure = null; + for (String namespace : new ArrayList<>(CONNECTIONS.keySet())) { + Set connections = CONNECTIONS.get(namespace); + if (connections == null) { + continue; + } + boolean stillOpen = false; + for (Connection connection : new ArrayList<>(connections)) { + try { + if (connection.isClosed()) { + unregisterConnection(connection); + try { + releaseTrackedConnection(namespace); + } catch (RuntimeException e) { + cleanupFailure = merge(cleanupFailure, e); + } + } else { + stillOpen = true; + } + } catch (SQLException | RuntimeException e) { + stillOpen = true; + } + } + if (stillOpen) { + open.add(namespace); + } + } + for (String namespace : new ArrayList<>(NAMESPACES.keySet())) { + Integer active = NAMESPACES.get(namespace); + if (active != null && active > 0 && !open.contains(namespace)) { + open.add(namespace); + } + } + if (open.isEmpty()) { + return cleanupFailure; + } + IllegalStateException failure = + new IllegalStateException( + "Conformance session stopped with " + + open.size() + + " namespace(s) still open: " + + open); + if (cleanupFailure != null) { + failure.addSuppressed(cleanupFailure); + } + return failure; + } + + private static void releaseTrackedConnection(String namespace) { + if (RETAINED_NAMESPACES.contains(namespace)) { + releaseConnection(namespace); + } else { + releaseNamespace(namespace); + } + } + + private static RuntimeException failedNamespacesFailure() { + if (FAILED_NAMESPACES.isEmpty()) { + return null; + } + IllegalStateException failure = + new IllegalStateException( + "Namespace cleanup failed for: " + FAILED_NAMESPACES.keySet()); + FAILED_NAMESPACES.values().forEach(failure::addSuppressed); + return failure; + } + private static RuntimeException reapRetainedNamespaces() { + return reapRetainedNamespaces(adminBaseUrl, DEFAULT_ADMIN); + } + + private static RuntimeException reapRetainedNamespaces( + String adminUrl, AdminClient admin) { RuntimeException failure = null; for (String namespace : RETAINED_NAMESPACES) { try { - deleteNamespace(namespace, adminBaseUrl, DEFAULT_ADMIN); + deleteNamespace(namespace, adminUrl, admin); } catch (RuntimeException e) { - if (failure == null) { - failure = e; - } else { - failure.addSuppressed(e); - } + failure = merge(failure, e); } } RETAINED_NAMESPACES.clear(); return failure; } + static synchronized void registerConnection(String namespace, Connection connection) { + CONNECTIONS + .computeIfAbsent( + namespace, + ignored -> Collections.newSetFromMap(new IdentityHashMap<>())) + .add(connection); + } + + static synchronized void unregisterConnection(Connection connection) { + Iterator>> iterator = + CONNECTIONS.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry> entry = iterator.next(); + entry.getValue().remove(connection); + if (entry.getValue().isEmpty()) { + iterator.remove(); + } + } + } + public static String jdbcUrl(String namespace) { return jdbcUrl(namespace, false); } diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServerCleanupTest.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServerCleanupTest.java index eb28c28..38f0782 100644 --- a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServerCleanupTest.java +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialTestServerCleanupTest.java @@ -5,8 +5,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.net.http.HttpRequest; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; class XerialTestServerCleanupTest { @@ -14,6 +17,7 @@ class XerialTestServerCleanupTest { private static final String ADMIN_URL = "http://admin.test"; private final FakeAdmin admin = new FakeAdmin(); + private final List namespaces = new ArrayList<>(); @Test void failedDeleteIsVisibleAndBlocksNamespaceReuse() { @@ -86,8 +90,15 @@ void createConflictIsNotTreatedAsSuccess() { assertThat(admin.createRequests).hasValue(1); } - private static String namespace() { - return "cleanup_" + UUID.randomUUID().toString().replace("-", ""); + @AfterEach + void resetTrackedNamespaces() { + namespaces.forEach(XerialTestServer::resetNamespaceForTest); + } + + private String namespace() { + String namespace = "cleanup_" + UUID.randomUUID().toString().replace("-", ""); + namespaces.add(namespace); + return namespace; } private static final class FakeAdmin implements XerialTestServer.AdminClient {