Merge "Make accountPatchReviewDb work with shared-disk multi-primary setup"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index d8c1f6b..b7bb053 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -56,6 +56,14 @@
url = jdbc:postgresql://<host>:<port>/<db_name>?user=<user>&password=<password>
----
+Use the following format to create a h2 file at a different path than default
+
+---
+[accountPatchReviewDb]
+ url = jdbc:h2:file:/path/to/db
+---
+
+
[[accountPatchReviewDb.poolLimit]]accountPatchReviewDb.poolLimit::
+
Maximum number of open database connections. If the server needs
@@ -101,6 +109,43 @@
If a unit suffix is not specified, `milliseconds` is assumed.
Default is `30 seconds`.
+[[accountPatchReviewDb.h2LockType]]accountPatchReviewDb.h2LockType::
++
+Selects the locking mechanism used to serialise access to the H2 database file,
+replacing H2's built-in file locking.
++
+When set, no connection pool is maintained. Each operation opens a fresh
+connection, holds the lock for the duration of that operation, and closes the
+connection immediately on completion.
++
+Supported values:
++
+--
+`jgit`:::
+Uses jgit-style lock files for inter-process mutual exclusion. H2 is opened with
+`FILE_LOCK=NO`; a `.lock` sidecar file is atomically created before opening each
+connection and deleted when the connection is closed. The maximum time to spend
+retrying the lock before failing is controlled by
+<<accountPatchReviewDb.h2LockTimeout, accountPatchReviewDb.h2LockTimeout>>.
+When using this option dont specify any custom options to the
+<<accountPatchReviewDb.url, accountPatchReviewDb.url>>.
++
+For this locking to work correctly across multiple Gerrit primaries, the db and
+`.lock` file must reside on a shared filesystem (e.g. NFS) accessible to all
+primaries. The lock file is placed next to the H2 database file (derived from
+`accountPatchReviewDb.url`, or `<site>/db/account_patch_reviews.lock` by default).
+--
++
+Default is unset (H2's built-in file locking is used).
+
+[[accountPatchReviewDb.h2LockTimeout]]accountPatchReviewDb.h2LockTimeout::
++
+Maximum time to spend retrying the external lock before giving up with an error.
+Only applies when `accountPatchReviewDb.h2LockType` is set. Retries use
+exponential backoff starting at 1 ms, capped at 500 ms per sleep.
++
+Default is `30 seconds`.
+
[[accounts]]
=== Section accounts
diff --git a/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
index c820e5a..944fb2c 100644
--- a/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
@@ -35,6 +35,10 @@
super(cfg, sitePaths, threadSettingsConfig);
}
+ protected H2AccountPatchReviewStore() {
+ super();
+ }
+
@Override
public StorageException convertError(String op, SQLException err) {
switch (getSQLStateInt(err)) {
diff --git a/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
new file mode 100644
index 0000000..678fea1
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// 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 com.google.gerrit.server.schema;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.SitePaths;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Abstract base for H2 stores that replace H2's built-in file locking with a custom mechanism.
+ *
+ * <p>H2 is opened with {@code FILE_LOCK=NO}; subclasses implement {@link #tryAcquireLock()} to make
+ * a single lock attempt. The retry loop with exponential backoff is handled here.
+ */
+abstract class H2CustomLockAccountPatchReviewStore extends H2AccountPatchReviewStore {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ private static final long INITIAL_BACKOFF_MS = 1;
+ private static final long MAX_BACKOFF_MS = 500;
+ static final long DEFAULT_LOCK_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(30);
+
+ private final String url;
+ private final long lockTimeoutMs;
+
+ protected H2CustomLockAccountPatchReviewStore(Config cfg, SitePaths sitePaths) {
+ super();
+ this.url =
+ JdbcAccountPatchReviewStore.getUrl(cfg, sitePaths) + ";FILE_LOCK=NO;DB_CLOSE_DELAY=0";
+ this.lockTimeoutMs =
+ ConfigUtil.getTimeUnit(
+ cfg,
+ JdbcAccountPatchReviewStore.ACCOUNT_PATCH_REVIEW_DB,
+ null,
+ "h2LockTimeout",
+ DEFAULT_LOCK_TIMEOUT_MS,
+ TimeUnit.MILLISECONDS);
+ }
+
+ protected long getLockTimeoutMs() {
+ return lockTimeoutMs;
+ }
+
+ /**
+ * Makes a single attempt to acquire the lock.
+ *
+ * @return a {@link Runnable} that releases the lock, or {@code null} if the lock is currently
+ * held by another process (retry-able)
+ * @throws SQLException on a hard, non-retryable error
+ */
+ @Nullable
+ protected abstract Runnable tryAcquireLock() throws SQLException;
+
+ private Runnable acquireExclusiveLock() throws SQLException {
+ long backoffMs = INITIAL_BACKOFF_MS;
+ long deadline = System.currentTimeMillis() + lockTimeoutMs;
+ while (true) {
+ Runnable unlock = tryAcquireLock();
+ if (unlock != null) {
+ return unlock;
+ }
+ long remaining = deadline - System.currentTimeMillis();
+ if (remaining <= 0) {
+ throw new SQLException("Could not acquire H2 lock within " + lockTimeoutMs + " ms");
+ }
+ long sleepMs = Math.min(backoffMs, remaining);
+ logger.atFine().log("H2 lock held by another process, retrying in %d ms", sleepMs);
+ try {
+ Thread.sleep(sleepMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw new SQLException("Interrupted while waiting for H2 lock", ie);
+ }
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
+ }
+ }
+
+ @Override
+ public Connection getConnection() throws SQLException {
+ Runnable unlock = acquireExclusiveLock();
+ try {
+ return lockingConnection(DriverManager.getConnection(url), unlock);
+ } catch (SQLException e) {
+ unlock.run();
+ throw e;
+ }
+ }
+
+ private static Connection lockingConnection(Connection con, Runnable unlock) {
+ return (Connection)
+ Proxy.newProxyInstance(
+ Connection.class.getClassLoader(),
+ new Class<?>[] {Connection.class},
+ (proxy, method, args) -> {
+ try {
+ return method.invoke(con, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ } finally {
+ if ("close".equals(method.getName())) {
+ unlock.run();
+ }
+ }
+ });
+ }
+}
diff --git a/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
new file mode 100644
index 0000000..d930013
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// 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 com.google.gerrit.server.schema;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.internal.storage.file.LockFile;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * H2 store using jgit-style {@link LockFile} locking for inter-process mutual exclusion.
+ *
+ * <p>Activated by setting {@code accountPatchReviewDb.h2LockType = jgit}. Each call to {@link
+ * #getConnection()} atomically creates a {@code .lock} sidecar file before opening H2 and deletes
+ * it when the connection is closed.
+ */
+@Singleton
+public class H2JGitLockAccountPatchReviewStore extends H2CustomLockAccountPatchReviewStore {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ private static final String H2_DB_URL_PREFIX = "jdbc:h2:file:";
+ private final File lockTarget;
+
+ @Inject
+ H2JGitLockAccountPatchReviewStore(@GerritServerConfig Config cfg, SitePaths sitePaths) {
+ super(cfg, sitePaths);
+ this.lockTarget = lockTargetFromUrl(JdbcAccountPatchReviewStore.getUrl(cfg, sitePaths));
+ }
+
+ @VisibleForTesting
+ static File lockTargetFromUrl(String h2Url) {
+ if (!h2Url.startsWith(H2_DB_URL_PREFIX)) {
+ throw new IllegalArgumentException("Not a valid H2 file URL: " + h2Url);
+ }
+
+ // URL format: "jdbc:h2:file:/path/to/db" - where ";" in the path is escaped as "\;"
+ String path = h2Url.substring(H2_DB_URL_PREFIX.length());
+
+ // Split on first unescaped ";" to drop options, then unescape "\;" in the path
+ return new File(
+ Iterables.get(Splitter.on(Pattern.compile("(?<!\\\\);")).split(path), 0)
+ .replace("\\;", ";"));
+ }
+
+ @Override
+ public void start() {
+ super.start();
+ logger.atInfo().log(
+ "AccountPatchReviewStore using H2 with jgit-style locking (h2LockType=jgit)."
+ + " lockFile=%s lockTimeout=%d ms",
+ lockTarget, getLockTimeoutMs());
+ }
+
+ @Nullable
+ @Override
+ protected Runnable tryAcquireLock() throws SQLException {
+ LockFile lock = new LockFile(lockTarget);
+ try {
+ return lock.lock() ? lock::unlock : null;
+ } catch (IOException e) {
+ throw new SQLException("Failed to acquire jgit-style lock for H2 database", e);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 3ca034d..fa53fe7 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -60,13 +60,15 @@
public static final String TEST_IN_MEMORY_URL =
"jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1";
- private static final String ACCOUNT_PATCH_REVIEW_DB = "accountPatchReviewDb";
+ static final String ACCOUNT_PATCH_REVIEW_DB = "accountPatchReviewDb";
private static final String H2_DB = "h2";
private static final String MARIADB = "mariadb";
private static final String MYSQL = "mysql";
private static final String POSTGRESQL = "postgresql";
private static final String CLOUDSPANNER = "cloudspanner";
private static final String URL = "url";
+ private static final String H2_LOCK_TYPE = "h2LockType";
+ static final String H2_LOCK_TYPE_JGIT = "jgit";
public static class JdbcAccountPatchReviewStoreModule extends LifecycleModule {
private final Config cfg;
@@ -80,7 +82,18 @@
Class<? extends JdbcAccountPatchReviewStore> impl;
String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
if (url == null || url.contains(H2_DB)) {
- impl = H2AccountPatchReviewStore.class;
+ String lockType = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, H2_LOCK_TYPE);
+ switch (lockType != null ? lockType : "") {
+ case "":
+ impl = H2AccountPatchReviewStore.class;
+ break;
+ case H2_LOCK_TYPE_JGIT:
+ impl = H2JGitLockAccountPatchReviewStore.class;
+ break;
+ default:
+ throw new IllegalArgumentException(
+ "Invalid accountPatchReviewDb.h2LockType value: " + lockType);
+ }
} else if (url.contains(POSTGRESQL)) {
impl = PostgresqlAccountPatchReviewStore.class;
} else if (url.contains(MYSQL)) {
@@ -104,7 +117,16 @@
Config cfg, SitePaths sitePaths, ThreadSettingsConfig threadSettingsConfig) {
String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
if (url == null || url.contains(H2_DB)) {
- return new H2AccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+ String lockType = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, H2_LOCK_TYPE);
+ switch (lockType != null ? lockType : "") {
+ case "":
+ return new H2AccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+ case H2_LOCK_TYPE_JGIT:
+ return new H2JGitLockAccountPatchReviewStore(cfg, sitePaths);
+ default:
+ throw new IllegalArgumentException(
+ "Invalid accountPatchReviewDb.h2LockType value: " + lockType);
+ }
}
if (url.contains(POSTGRESQL)) {
return new PostgresqlAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
@@ -127,7 +149,12 @@
this.ds = createDataSource(cfg, sitePaths, threadSettingsConfig);
}
- private static String getUrl(@GerritServerConfig Config cfg, SitePaths sitePaths) {
+ // Used by subclasses that manage their own connections without a pool.
+ protected JdbcAccountPatchReviewStore() {
+ this.ds = null;
+ }
+
+ static String getUrl(@GerritServerConfig Config cfg, SitePaths sitePaths) {
String url = cfg.getString(ACCOUNT_PATCH_REVIEW_DB, null, URL);
if (url == null) {
return createH2Url(sitePaths.db_dir.resolve("account_patch_reviews"));
@@ -191,7 +218,7 @@
}
public void createTableIfNotExists() {
- try (Connection con = ds.getConnection();
+ try (Connection con = getConnection();
Statement stmt = con.createStatement()) {
doCreateTable(stmt);
} catch (SQLException e) {
@@ -212,7 +239,7 @@
}
public void dropTableIfExists() {
- try (Connection con = ds.getConnection();
+ try (Connection con = getConnection();
Statement stmt = con.createStatement()) {
stmt.executeUpdate("DROP TABLE IF EXISTS account_patch_reviews");
} catch (SQLException e) {
@@ -233,7 +260,7 @@
.accountId(accountId.get())
.filePath(path)
.build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement(
"INSERT INTO account_patch_reviews "
@@ -268,7 +295,7 @@
.accountId(accountId.get())
.resourceCount(paths.size())
.build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement(
"INSERT INTO account_patch_reviews "
@@ -301,7 +328,7 @@
.accountId(accountId.get())
.filePath(path)
.build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement(
"DELETE FROM account_patch_reviews "
@@ -323,7 +350,7 @@
TraceContext.newTimer(
"Clear all reviewed flags of patch set",
Metadata.builder().patchSetId(psId.get()).build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement(
"DELETE FROM account_patch_reviews "
@@ -342,7 +369,7 @@
TraceContext.newTimer(
"Clear all reviewed flags of change",
Metadata.builder().changeId(changeId.get()).build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement("DELETE FROM account_patch_reviews WHERE change_id = ?")) {
stmt.setInt(1, changeId.get());
@@ -358,7 +385,7 @@
TraceContext.newTimer(
"Clear all reviewed flags by user",
Metadata.builder().accountId(accountId.get()).build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement("DELETE FROM account_patch_reviews WHERE account_id = ?")) {
stmt.setInt(1, accountId.get());
@@ -374,7 +401,7 @@
TraceContext.newTimer(
"Find reviewed flags",
Metadata.builder().patchSetId(psId.get()).accountId(accountId.get()).build());
- Connection con = ds.getConnection();
+ Connection con = getConnection();
PreparedStatement stmt =
con.prepareStatement(
"SELECT patch_set_id, file_name FROM account_patch_reviews APR1 "
diff --git a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
new file mode 100644
index 0000000..bac1b31
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
@@ -0,0 +1,125 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// 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 com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.config.SitePaths;
+import java.nio.file.Files;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class H2JGitLockAccountPatchReviewStoreIT {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+ private H2JGitLockAccountPatchReviewStore store;
+ private static final Account.Id ACCOUNT = Account.id(1);
+ private static final PatchSet.Id PS = PatchSet.id(Change.id(1), 1);
+ private static final String FILE = "foo/bar.txt";
+
+ @Before
+ public void setUp() throws Exception {
+ SitePaths sitePaths = new SitePaths(temporaryFolder.getRoot().toPath());
+ Files.createDirectories(sitePaths.db_dir);
+ Config cfg = new Config();
+ store = new H2JGitLockAccountPatchReviewStore(cfg, sitePaths);
+ store.start();
+ }
+
+ @Test
+ public void markAndFindReviewed() {
+ assertThat(store.findReviewed(PS, ACCOUNT)).isEmpty();
+
+ var unused = store.markReviewed(PS, ACCOUNT, FILE);
+
+ assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+ assertThat(store.findReviewed(PS, ACCOUNT).get().files()).containsExactly(FILE);
+ }
+
+ @Test
+ public void clearReviewedFile() {
+ var unused = store.markReviewed(PS, ACCOUNT, FILE);
+ assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+
+ store.clearReviewed(PS, ACCOUNT, FILE);
+
+ assertThat(store.findReviewed(PS, ACCOUNT)).isEmpty();
+ }
+
+ @Test
+ public void clearReviewedPatchSet() {
+ var unused = store.markReviewed(PS, ACCOUNT, FILE);
+ assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+ assertThat(store.findReviewed(PS, ACCOUNT).get().files()).containsExactly(FILE);
+
+ store.clearReviewed(PS);
+
+ assertThat(store.findReviewed(PS, ACCOUNT)).isEmpty();
+ }
+
+ @Test
+ public void concurrentMarksAreAllDurable() throws Exception {
+ int nThreads = 8;
+ int filesPerThread = 5;
+ CyclicBarrier startGate = new CyclicBarrier(nThreads);
+
+ try (ExecutorService executor = Executors.newFixedThreadPool(nThreads)) {
+ ImmutableList<Future<?>> futures =
+ IntStream.range(0, nThreads)
+ .mapToObj(
+ threadIdx ->
+ executor.submit(
+ () -> {
+ startGate.await();
+ for (int f = 0; f < filesPerThread; f++) {
+ var unused = store.markReviewed(PS, ACCOUNT, fileName(threadIdx, f));
+ }
+ return null;
+ }))
+ .collect(ImmutableList.toImmutableList());
+
+ for (Future<?> future : futures) {
+ future.get();
+ }
+ }
+
+ ImmutableSet<String> expected =
+ IntStream.range(0, nThreads)
+ .boxed()
+ .flatMap(
+ threadIdx ->
+ IntStream.range(0, filesPerThread).mapToObj(f -> fileName(threadIdx, f)))
+ .collect(ImmutableSet.toImmutableSet());
+
+ assertThat(store.findReviewed(PS, ACCOUNT)).isPresent();
+ assertThat(store.findReviewed(PS, ACCOUNT).get().files()).isEqualTo(expected);
+ }
+
+ private static String fileName(int threadIdx, int fileIdx) {
+ return "file-" + threadIdx + "-" + fileIdx + ".txt";
+ }
+}
diff --git a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
new file mode 100644
index 0000000..c15d1c8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// 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 com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.schema.H2JGitLockAccountPatchReviewStore.lockTargetFromUrl;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.io.File;
+import org.junit.Test;
+
+public class H2JGitLockAccountPatchReviewStoreTest {
+ @Test
+ public void lockTargetFromUrl_plainPath() {
+ assertThat(lockTargetFromUrl("jdbc:h2:file:/path/to/db")).isEqualTo(new File("/path/to/db"));
+ }
+
+ @Test
+ public void lockTargetFromUrl_stripsOptions() {
+ assertThat(lockTargetFromUrl("jdbc:h2:file:/path/to/db;FILE_LOCK=NO;DB_CLOSE_DELAY=0"))
+ .isEqualTo(new File("/path/to/db"));
+ }
+
+ @Test
+ public void lockTargetFromUrl_unescapesSemicolonInPath() {
+ assertThat(lockTargetFromUrl("jdbc:h2:file:/path/with\\;semi/db;FILE_LOCK=NO"))
+ .isEqualTo(new File("/path/with;semi/db"));
+ }
+
+ @Test
+ public void lockTargetFromUrl_throwsOnInvalidUrls() {
+ assertThrows(
+ IllegalArgumentException.class, () -> lockTargetFromUrl("jdbc:h2:mem:/path/to/db"));
+ }
+}