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 {