From 21a5da28f33f01b60f6ba8b36dd932bf81732383 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:26:19 +0200 Subject: [PATCH] fix(conformance): persist file URLs across close and reopen (#43) --- libsql-conformance/README.md | 37 ++++++++++ .../conformance/XerialConformanceDriver.java | 62 ++++++++++++----- .../XerialFileUrlPersistenceTest.java | 65 ++++++++++++++++++ .../libsql/conformance/XerialTestServer.java | 68 ++++++++++++++++++- 4 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 libsql-conformance/README.md create mode 100644 libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialFileUrlPersistenceTest.java diff --git a/libsql-conformance/README.md b/libsql-conformance/README.md new file mode 100644 index 0000000..2141345 --- /dev/null +++ b/libsql-conformance/README.md @@ -0,0 +1,37 @@ +# libSQL xerial Conformance Tests + +This module runs a selected subset of the xerial `sqlite-jdbc` test sources against +libSQL over the Hrana protocol. It is a compatibility check for the JDBC driver, not a +claim that a `jdbc:sqlite:` URL is served by a local SQLite file. + +## URL mapping + +The conformance driver maps `jdbc:sqlite:` URLs to sqld namespaces so the vendored +tests can run unchanged: + +| URL | Namespace | +|---|---| +| `jdbc:sqlite:` | A new in-memory namespace per connection | +| `jdbc:sqlite::memory:` | A new in-memory namespace per connection | +| `jdbc:sqlite:` | One namespace derived from `` | + +A file path is resolved against the process working directory and normalized before +it is hashed. A relative path and the equivalent absolute path therefore address the +same namespace; for example, `data/example.db` and `/work/data/example.db` match when +the process working directory is `/work`. Normalization resolves `.` and `..` but does +not resolve symbolic links. + +The query string is ignored when deriving the namespace, so URL pragmas such as +`?journal_mode=WAL` have no effect on the conformance server. No local SQLite file is +created; the data lives in the sqld namespace. + +## Persistence + +A path namespace is retained until the suite shuts down, so closing every connection +and reopening the same path observes the data written earlier. The retained namespaces +are deleted during suite shutdown and the shared sqld container is stopped. In-memory +namespaces are not retained. + +The conformance copy of an upstream test diverges from xerial where Hrana connection +lifecycle makes the original behavior impossible; each divergence is recorded in +`../upstream-xerial_sqlite-jdbc.manifest`. 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 b59f778..a5baa2d 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 @@ -7,6 +7,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; @@ -19,6 +20,16 @@ import java.util.function.Consumer; import java.util.logging.Logger; +/** + * Maps the {@code jdbc:sqlite:} URLs used by the xerial test sources onto sqld namespaces. + * + *

An empty path and {@code :memory:} map to a fresh namespace per connection. Every other path + * 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. + */ public final class XerialConformanceDriver implements Driver { private static final String PREFIX = "jdbc:sqlite:"; @@ -45,11 +56,11 @@ public Connection connect(String url, Properties info) throws SQLException { try { connection = delegate.connect(target.url(), info); } catch (SQLException e) { - releaseAfterConnectFailure(target.namespace(), e); + releaseAfterConnectFailure(target, e); throw e; } if (connection == null) { - XerialTestServer.releaseNamespace(target.namespace()); + release(target); return null; } try { @@ -57,10 +68,11 @@ public Connection connect(String url, Properties info) throws SQLException { Proxy.newProxyInstance( XerialConformanceDriver.class.getClassLoader(), new Class[] {Connection.class}, - new NamespaceClosingHandler(connection, target.namespace())); + new NamespaceClosingHandler( + connection, target.namespace(), releaser(target))); } catch (RuntimeException e) { try { - XerialTestServer.releaseNamespace(target.namespace()); + release(target); } catch (RuntimeException cleanup) { e.addSuppressed(cleanup); } @@ -101,30 +113,46 @@ public Logger getParentLogger() throws SQLFeatureNotSupportedException { return delegate.getParentLogger(); } - private static void releaseAfterConnectFailure(String namespace, SQLException failure) { + private static void releaseAfterConnectFailure(Target target, SQLException failure) { try { - XerialTestServer.releaseNamespace(namespace); + release(target); } catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); } } + private static void release(Target target) { + releaser(target).accept(target.namespace()); + } + + private static Consumer releaser(Target target) { + return target.retained() + ? XerialTestServer::releaseRetainedConnection + : XerialTestServer::releaseNamespace; + } + private static Target rewrite(String url) { String target = url.substring(PREFIX.length()); - String namespace; - if (target.isEmpty() || ":memory:".equals(target)) { - namespace = "mem_" + UUID.randomUUID().toString().replace("-", ""); - } else { - namespace = - "file_" - + UUID.nameUUIDFromBytes(target.getBytes(StandardCharsets.UTF_8)) - .toString() - .replace("-", ""); + String path = stripQuery(target); + if (path.isEmpty() || ":memory:".equals(path)) { + String namespace = "mem_" + UUID.randomUUID().toString().replace("-", ""); + return new Target(XerialTestServer.jdbcUrl(namespace, false), namespace, false); } - return new Target(XerialTestServer.jdbcUrl(namespace), namespace); + String normalized = Path.of(path).toAbsolutePath().normalize().toString(); + String namespace = + "file_" + + UUID.nameUUIDFromBytes(normalized.getBytes(StandardCharsets.UTF_8)) + .toString() + .replace("-", ""); + return new Target(XerialTestServer.jdbcUrl(namespace, true), namespace, true); + } + + private static String stripQuery(String target) { + int query = target.indexOf('?'); + return query < 0 ? target : target.substring(0, query); } - private record Target(String url, String namespace) {} + private record Target(String url, String namespace, boolean retained) {} static final class NamespaceClosingHandler implements InvocationHandler { diff --git a/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialFileUrlPersistenceTest.java b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialFileUrlPersistenceTest.java new file mode 100644 index 0000000..cae98d6 --- /dev/null +++ b/libsql-conformance/src/test/java/com/github/magnusp/libsql/conformance/XerialFileUrlPersistenceTest.java @@ -0,0 +1,65 @@ +package com.github.magnusp.libsql.conformance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class XerialFileUrlPersistenceTest { + + @TempDir Path tempDir; + + @Test + void fileUrlKeepsDataAfterEveryConnectionClosesAndTheSamePathReopens() throws SQLException { + Path database = tempDir.resolve("persist.db").toAbsolutePath().normalize(); + String url = "jdbc:sqlite:" + database; + + try (Connection connection = DriverManager.getConnection(url); + Statement statement = connection.createStatement()) { + statement.executeUpdate( + "CREATE TABLE persisted (id INTEGER PRIMARY KEY, value TEXT)"); + statement.executeUpdate("INSERT INTO persisted (value) VALUES ('kept')"); + } + + try (Connection connection = DriverManager.getConnection(url); + Statement statement = connection.createStatement(); + ResultSet resultSet = + statement.executeQuery("SELECT value FROM persisted WHERE id = 1")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString(1)).isEqualTo("kept"); + } + } + + @Test + void relativeAndAbsolutePathsAddressTheSameFile() throws SQLException { + Path database = tempDir.resolve("mapped.db").toAbsolutePath().normalize(); + Path workingDirectory = Path.of("").toAbsolutePath().normalize(); + assumeTrue( + workingDirectory.getRoot().equals(database.getRoot()), + "The working directory and test directory must share a filesystem root"); + Path relative = workingDirectory.relativize(database); + assumeTrue(!relative.isAbsolute(), "The test database must be addressable relatively"); + + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + database); + Statement statement = connection.createStatement()) { + statement.executeUpdate( + "CREATE TABLE mapped (id INTEGER PRIMARY KEY, value TEXT)"); + statement.executeUpdate("INSERT INTO mapped (value) VALUES ('same file')"); + } + + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + relative); + Statement statement = connection.createStatement(); + ResultSet resultSet = + statement.executeQuery("SELECT value FROM mapped WHERE id = 1")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString(1)).isEqualTo("same file"); + } + } +} 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 7fa9003..d06424e 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 @@ -10,6 +10,7 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public final class XerialTestServer { @@ -32,6 +33,7 @@ public final class XerialTestServer { return new AdminResponse(response.statusCode(), response.body()); }; private static final Map NAMESPACES = new ConcurrentHashMap<>(); + private static final Set RETAINED_NAMESPACES = ConcurrentHashMap.newKeySet(); private static final Map FAILED_NAMESPACES = new ConcurrentHashMap<>(); @@ -86,20 +88,52 @@ public static synchronized void stop() { if (container == null) { return; } + RuntimeException failure = reapRetainedNamespaces(); try { container.stop(); + } catch (RuntimeException stopFailure) { + if (failure == null) { + failure = stopFailure; + } else { + failure.addSuppressed(stopFailure); + } } finally { container = null; httpBaseUrl = null; adminBaseUrl = null; NAMESPACES.clear(); + RETAINED_NAMESPACES.clear(); FAILED_NAMESPACES.clear(); } + if (failure != null) { + throw failure; + } + } + + private static RuntimeException reapRetainedNamespaces() { + RuntimeException failure = null; + for (String namespace : RETAINED_NAMESPACES) { + try { + deleteNamespace(namespace, adminBaseUrl, DEFAULT_ADMIN); + } catch (RuntimeException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + RETAINED_NAMESPACES.clear(); + return failure; } public static String jdbcUrl(String namespace) { + return jdbcUrl(namespace, false); + } + + static String jdbcUrl(String namespace, boolean retained) { start(); - ensureNamespace(namespace); + ensureNamespace(namespace, retained, adminBaseUrl, DEFAULT_ADMIN); return "jdbc:sqlite:" + httpBaseUrl + "/dev/" + namespace; } @@ -107,6 +141,22 @@ public static synchronized void releaseNamespace(String namespace) { releaseNamespace(namespace, adminBaseUrl, DEFAULT_ADMIN); } + public static synchronized void releaseRetainedConnection(String namespace) { + releaseConnection(namespace); + } + + static synchronized void releaseConnection(String namespace) { + int active = NAMESPACES.getOrDefault(namespace, 0); + if (active == 0) { + return; + } + if (active > 1) { + NAMESPACES.put(namespace, active - 1); + return; + } + NAMESPACES.remove(namespace); + } + static synchronized void releaseNamespace( String namespace, String adminUrl, AdminClient admin) { int active = NAMESPACES.getOrDefault(namespace, 0); @@ -140,6 +190,11 @@ static synchronized void ensureNamespace(String namespace) { static synchronized void ensureNamespace( String namespace, String adminUrl, AdminClient admin) { + ensureNamespace(namespace, false, adminUrl, admin); + } + + static synchronized void ensureNamespace( + String namespace, boolean retained, String adminUrl, AdminClient admin) { RuntimeException failed = FAILED_NAMESPACES.get(namespace); if (failed != null) { throw new IllegalStateException( @@ -152,6 +207,14 @@ static synchronized void ensureNamespace( int active = NAMESPACES.getOrDefault(namespace, 0); if (active > 0) { NAMESPACES.put(namespace, active + 1); + if (retained) { + RETAINED_NAMESPACES.add(namespace); + } + return; + } + + if (retained && RETAINED_NAMESPACES.contains(namespace)) { + NAMESPACES.put(namespace, 1); return; } @@ -183,6 +246,9 @@ static synchronized void ensureNamespace( int status = response.status(); if (status >= 200 && status < 300) { NAMESPACES.put(namespace, 1); + if (retained) { + RETAINED_NAMESPACES.add(namespace); + } return; }