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"));
+  }
+}