diff --git a/java/google/registry/model/server/Lock.java b/java/google/registry/model/server/Lock.java
index 1b60df3b5..a294f658f 100644
--- a/java/google/registry/model/server/Lock.java
+++ b/java/google/registry/model/server/Lock.java
@@ -15,12 +15,11 @@
package google.registry.model.server;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Throwables.throwIfUnchecked;
import static google.registry.model.ofy.ObjectifyService.ofy;
import static google.registry.util.DateTimeUtils.isAtOrAfter;
+import com.google.common.base.Optional;
import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSortedSet;
import com.googlecode.objectify.VoidWork;
import com.googlecode.objectify.Work;
import com.googlecode.objectify.annotation.Entity;
@@ -28,19 +27,21 @@ import com.googlecode.objectify.annotation.Id;
import google.registry.model.ImmutableObject;
import google.registry.model.annotations.NotBackedUp;
import google.registry.model.annotations.NotBackedUp.Reason;
-import google.registry.util.AppEngineTimeLimiter;
import google.registry.util.FormattingLogger;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.TimeUnit;
+import google.registry.util.RequestStatusChecker;
+import google.registry.util.RequestStatusCheckerImpl;
import javax.annotation.Nullable;
import org.joda.time.DateTime;
import org.joda.time.Duration;
/**
- * A lock on some shared resource. Locks are either specific to a tld or global to the entire
- * system, in which case a tld of null is used.
+ * A lock on some shared resource.
+ *
+ *
Locks are either specific to a tld or global to the entire system, in which case a tld of
+ * null is used.
+ *
+ *
This is the "barebone" lock implementation, that requires manual locking and unlocking. For
+ * safe calls that automatically lock and unlock, see LockHandler.
*/
@Entity
@NotBackedUp(reason = Reason.TRANSIENT)
@@ -48,13 +49,21 @@ public class Lock extends ImmutableObject {
private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass();
- /** Fudge factor to make sure we kill threads before a lock actually expires. */
- private static final Duration LOCK_TIMEOUT_FUDGE = Duration.standardSeconds(5);
-
/** The name of the locked resource. */
@Id
String lockId;
+ /**
+ * Unique log ID of the request that owns this lock.
+ *
+ *
When that request is no longer running (is finished), the lock can be considered implicitly
+ * released.
+ *
+ *
See {@link RequestStatusCheckerImpl#getLogId} for details about how it's created in
+ * practice.
+ */
+ String requestLogId;
+
/** When the lock can be considered implicitly released. */
DateTime expirationTime;
@@ -65,12 +74,14 @@ public class Lock extends ImmutableObject {
private static Lock create(
String resourceName,
@Nullable String tld,
+ String requestLogId,
DateTime expirationTime) {
checkArgument(!Strings.isNullOrEmpty(resourceName), "resourceName cannot be null or empty");
Lock instance = new Lock();
// Add the tld to the Lock's id so that it is unique for locks acquiring the same resource
// across different TLDs.
instance.lockId = makeLockId(resourceName, tld);
+ instance.requestLogId = requestLogId;
instance.expirationTime = expirationTime;
return instance;
}
@@ -79,15 +90,16 @@ public class Lock extends ImmutableObject {
return String.format("%s-%s", tld, resourceName);
}
- /** Try to acquire a lock. Returns null if it can't be acquired. */
- static Lock acquire(
+ /** Try to acquire a lock. Returns absent if it can't be acquired. */
+ public static Optional acquire(
final String resourceName,
@Nullable final String tld,
- final Duration leaseLength) {
+ final Duration leaseLength,
+ final RequestStatusChecker requestStatusChecker) {
// It's important to use transactNew rather than transact, because a Lock can be used to control
// access to resources like GCS that can't be transactionally rolled back. Therefore, the lock
// must be definitively acquired before it is used, even when called inside another transaction.
- return ofy().transactNew(new Work() {
+ return Optional.fromNullable(ofy().transactNew(new Work() {
@Override
public Lock run() {
String lockId = makeLockId(resourceName, tld);
@@ -95,9 +107,19 @@ public class Lock extends ImmutableObject {
// Checking if an unexpired lock still exists - if so, the lock can't be acquired.
Lock lock = ofy().load().type(Lock.class).id(lockId).now();
- if (lock != null && !isAtOrAfter(now, lock.expirationTime)) {
+ if (lock != null) {
logger.infofmt(
- "Existing lock is still valid now %s (until %s) lock: %s",
+ "Loaded existing lock: %s for request: %s", lock.lockId, lock.requestLogId);
+ }
+ // TODO(b/63982642): remove check on requestLogId being null once migration is done
+ // Until then we assume missing requestLogId means the app is still running (since we have
+ // no information to the contrary)
+ if (lock != null
+ && !isAtOrAfter(now, lock.expirationTime)
+ && (lock.requestLogId == null || requestStatusChecker.isRunning(lock.requestLogId))) {
+ logger.infofmt(
+ "Existing lock by request %s is still valid now %s (until %s) lock: %s",
+ lock.requestLogId,
now,
lock.expirationTime,
lockId);
@@ -106,7 +128,8 @@ public class Lock extends ImmutableObject {
if (lock != null) {
logger.infofmt(
- "Existing lock is timed out now %s (was valid until %s) lock: %s",
+ "Existing lock by request %s is timed out now %s (was valid until %s) lock: %s",
+ lock.requestLogId,
now,
lock.expirationTime,
lockId);
@@ -114,6 +137,7 @@ public class Lock extends ImmutableObject {
Lock newLock = create(
resourceName,
tld,
+ requestStatusChecker.getLogId(),
now.plus(leaseLength));
// Locks are not parented under an EntityGroupRoot (so as to avoid write contention) and
// don't need to be backed up.
@@ -123,11 +147,11 @@ public class Lock extends ImmutableObject {
newLock,
lockId);
return newLock;
- }});
+ }}));
}
/** Release the lock. */
- void release() {
+ public void release() {
// Just use the default clock because we aren't actually doing anything that will use the clock.
ofy().transact(new VoidWork() {
@Override
@@ -151,74 +175,4 @@ public class Lock extends ImmutableObject {
}
}});
}
-
- /**
- * Acquire one or more locks and execute a Void {@link Callable} on a thread that will be
- * killed if it doesn't complete before the lease expires.
- *
- * Note that locks are specific either to a given tld or to the entire system (in which case
- * tld should be passed as null).
- *
- * @return whether all locks were acquired and the callable was run.
- */
- public static boolean executeWithLocks(
- final Callable callable,
- @Nullable String tld,
- Duration leaseLength,
- String... lockNames) {
- try {
- return AppEngineTimeLimiter.create().callWithTimeout(
- new LockingCallable(callable, Strings.emptyToNull(tld), leaseLength, lockNames),
- leaseLength.minus(LOCK_TIMEOUT_FUDGE).getMillis(),
- TimeUnit.MILLISECONDS,
- true);
- } catch (Exception e) {
- throwIfUnchecked(e);
- throw new RuntimeException(e);
- }
- }
-
- /** A {@link Callable} that acquires and releases a lock around a delegate {@link Callable}. */
- private static class LockingCallable implements Callable {
- final Callable delegate;
- @Nullable final String tld;
- final Duration leaseLength;
- final Set lockNames;
-
- LockingCallable(
- Callable delegate,
- String tld,
- Duration leaseLength,
- String... lockNames) {
- checkArgument(leaseLength.isLongerThan(LOCK_TIMEOUT_FUDGE));
- this.delegate = delegate;
- this.tld = tld;
- this.leaseLength = leaseLength;
- // Make sure we join locks in a fixed (lexicographical) order to avoid deadlock.
- this.lockNames = ImmutableSortedSet.copyOf(lockNames);
- }
-
- @Override
- public Boolean call() throws Exception {
- Set acquiredLocks = new HashSet<>();
- try {
- for (String lockName : lockNames) {
- Lock lock = acquire(lockName, tld, leaseLength);
- if (lock == null) {
- logger.infofmt("Couldn't acquire lock: %s", lockName);
- return false;
- }
- logger.infofmt("Acquired lock: %s", lockName);
- acquiredLocks.add(lock);
- }
- delegate.call();
- return true;
- } finally {
- for (Lock lock : acquiredLocks) {
- lock.release();
- logger.infofmt("Released lock: %s", lock.lockId);
- }
- }
- }
- }
}
diff --git a/java/google/registry/request/RequestModule.java b/java/google/registry/request/RequestModule.java
index 68b18a89c..cc8b6d533 100644
--- a/java/google/registry/request/RequestModule.java
+++ b/java/google/registry/request/RequestModule.java
@@ -28,7 +28,7 @@ import google.registry.request.HttpException.BadRequestException;
import google.registry.request.HttpException.UnsupportedMediaTypeException;
import google.registry.request.auth.AuthResult;
import google.registry.request.lock.LockHandler;
-import google.registry.request.lock.LockHandlerPassthrough;
+import google.registry.request.lock.LockHandlerImpl;
import google.registry.util.RequestStatusChecker;
import google.registry.util.RequestStatusCheckerImpl;
import java.io.IOException;
@@ -127,7 +127,7 @@ public final class RequestModule {
}
@Provides
- static LockHandler provideLockHandler(LockHandlerPassthrough lockHandler) {
+ static LockHandler provideLockHandler(LockHandlerImpl lockHandler) {
return lockHandler;
}
diff --git a/java/google/registry/request/lock/BUILD b/java/google/registry/request/lock/BUILD
index 7e69134ca..061079191 100644
--- a/java/google/registry/request/lock/BUILD
+++ b/java/google/registry/request/lock/BUILD
@@ -9,8 +9,10 @@ java_library(
srcs = glob(["*.java"]),
deps = [
"//java/google/registry/model",
+ "//java/google/registry/util",
"@com_google_code_findbugs_jsr305",
"@com_google_dagger",
+ "@com_google_guava",
"@joda_time",
],
)
diff --git a/java/google/registry/request/lock/LockHandlerImpl.java b/java/google/registry/request/lock/LockHandlerImpl.java
new file mode 100644
index 000000000..b6df55f89
--- /dev/null
+++ b/java/google/registry/request/lock/LockHandlerImpl.java
@@ -0,0 +1,127 @@
+// Copyright 2017 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.request.lock;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Throwables.throwIfUnchecked;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSortedSet;
+import google.registry.model.server.Lock;
+import google.registry.util.AppEngineTimeLimiter;
+import google.registry.util.FormattingLogger;
+import google.registry.util.RequestStatusChecker;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import org.joda.time.Duration;
+
+/** Implementation of {@link LockHandler} that uses the datastore lock. */
+public class LockHandlerImpl implements LockHandler {
+
+ private static final long serialVersionUID = 6551645164118637767L;
+
+ private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass();
+
+ /** Fudge factor to make sure we kill threads before a lock actually expires. */
+ private static final Duration LOCK_TIMEOUT_FUDGE = Duration.standardSeconds(5);
+
+ @Inject RequestStatusChecker requestStatusChecker;
+
+ @Inject public LockHandlerImpl() {}
+
+ /**
+ * Acquire one or more locks and execute a Void {@link Callable}.
+ *
+ * Thread will be killed if it doesn't complete before the lease expires.
+ *
+ *
Note that locks are specific either to a given tld or to the entire system (in which case
+ * tld should be passed as null).
+ *
+ * @return whether all locks were acquired and the callable was run.
+ */
+ @Override
+ public boolean executeWithLocks(
+ final Callable callable,
+ @Nullable String tld,
+ Duration leaseLength,
+ String... lockNames) {
+ try {
+ return AppEngineTimeLimiter.create().callWithTimeout(
+ new LockingCallable(callable, Strings.emptyToNull(tld), leaseLength, lockNames),
+ leaseLength.minus(LOCK_TIMEOUT_FUDGE).getMillis(),
+ TimeUnit.MILLISECONDS,
+ true);
+ } catch (Exception e) {
+ throwIfUnchecked(e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Allows injection of mock Lock in tests. */
+ @VisibleForTesting
+ Optional acquire(String lockName, @Nullable String tld, Duration leaseLength) {
+ return Lock.acquire(lockName, tld, leaseLength, requestStatusChecker);
+ }
+
+ /** A {@link Callable} that acquires and releases a lock around a delegate {@link Callable}. */
+ private class LockingCallable implements Callable {
+ final Callable delegate;
+ @Nullable final String tld;
+ final Duration leaseLength;
+ final Set lockNames;
+
+ LockingCallable(
+ Callable delegate,
+ String tld,
+ Duration leaseLength,
+ String... lockNames) {
+ checkArgument(leaseLength.isLongerThan(LOCK_TIMEOUT_FUDGE));
+ this.delegate = delegate;
+ this.tld = tld;
+ this.leaseLength = leaseLength;
+ // Make sure we join locks in a fixed (lexicographical) order to avoid deadlock.
+ this.lockNames = ImmutableSortedSet.copyOf(lockNames);
+ }
+
+ @Override
+ public Boolean call() throws Exception {
+ Set acquiredLocks = new HashSet<>();
+ try {
+ for (String lockName : lockNames) {
+ Optional lock = acquire(lockName, tld, leaseLength);
+ if (!lock.isPresent()) {
+ logger.infofmt("Couldn't acquire lock named: %s for TLD: %s", lockName, tld);
+ return false;
+ }
+ logger.infofmt("Acquired lock: %s", lock);
+ acquiredLocks.add(lock.get());
+ }
+ delegate.call();
+ return true;
+ } finally {
+ for (Lock lock : acquiredLocks) {
+ lock.release();
+ logger.infofmt("Released lock: %s", lock);
+ }
+ }
+ }
+ }
+}
diff --git a/java/google/registry/request/lock/LockHandlerPassthrough.java b/java/google/registry/request/lock/LockHandlerPassthrough.java
deleted file mode 100644
index 0d0a68f76..000000000
--- a/java/google/registry/request/lock/LockHandlerPassthrough.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright 2017 The Nomulus Authors. All Rights Reserved.
-//
-// 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 google.registry.request.lock;
-
-import google.registry.model.server.Lock;
-import java.util.concurrent.Callable;
-import javax.annotation.Nullable;
-import javax.inject.Inject;
-import org.joda.time.Duration;
-
-/**
- * Implementation of {@link LockHandler} that uses Lock as is.
- *
- * This is a temporary implementation to help migrate from Lock to LockHandler. Once the migration
- * is complete - we will create a "proper" LockHandlerImpl class and remove this one.
- *
- * TODO(guyben):delete this class once LockHandlerImpl is done.
- */
-public class LockHandlerPassthrough implements LockHandler {
-
- private static final long serialVersionUID = 6551645164118637767L;
-
- @Inject public LockHandlerPassthrough() {}
-
- /**
- * Acquire one or more locks and execute a Void {@link Callable}.
- *
- * Runs on a thread that will be killed if it doesn't complete before the lease expires.
- *
- *
This is a simple passthrough to {@link Lock#executeWithLocks}.
- *
- * @return true if all locks were acquired and the callable was run; false otherwise.
- */
- @Override
- public boolean executeWithLocks(
- final Callable callable,
- @Nullable String tld,
- Duration leaseLength,
- String... lockNames) {
- return Lock.executeWithLocks(callable, tld, leaseLength, lockNames);
- }
-}
diff --git a/javatests/google/registry/model/BUILD b/javatests/google/registry/model/BUILD
index fe833b57d..4705c48bc 100644
--- a/javatests/google/registry/model/BUILD
+++ b/javatests/google/registry/model/BUILD
@@ -38,6 +38,7 @@ java_library(
"@joda_time",
"@junit",
"@org_joda_money",
+ "@org_mockito_all",
],
)
diff --git a/javatests/google/registry/model/schema.txt b/javatests/google/registry/model/schema.txt
index fc0e9a479..688a924dc 100644
--- a/javatests/google/registry/model/schema.txt
+++ b/javatests/google/registry/model/schema.txt
@@ -876,6 +876,7 @@ class google.registry.model.server.KmsSecretRevision {
}
class google.registry.model.server.Lock {
@Id java.lang.String lockId;
+ java.lang.String requestLogId;
org.joda.time.DateTime expirationTime;
}
class google.registry.model.server.ServerSecret {
diff --git a/javatests/google/registry/model/server/LockTest.java b/javatests/google/registry/model/server/LockTest.java
index d16897fd7..8b7c72350 100644
--- a/javatests/google/registry/model/server/LockTest.java
+++ b/javatests/google/registry/model/server/LockTest.java
@@ -15,13 +15,18 @@
package google.registry.model.server;
import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import com.google.common.base.Optional;
import google.registry.model.ofy.Ofy;
import google.registry.testing.AppEngineRule;
import google.registry.testing.ExceptionRule;
import google.registry.testing.FakeClock;
import google.registry.testing.InjectRule;
+import google.registry.util.RequestStatusChecker;
import org.joda.time.Duration;
+import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -34,6 +39,7 @@ public class LockTest {
private static final String RESOURCE_NAME = "foo";
private static final Duration ONE_DAY = Duration.standardDays(1);
private static final Duration TWO_MILLIS = Duration.millis(2);
+ private static final RequestStatusChecker requestStatusChecker = mock(RequestStatusChecker.class);
@Rule
public final AppEngineRule appEngine = AppEngineRule.builder()
@@ -46,49 +52,68 @@ public class LockTest {
@Rule
public final ExceptionRule thrown = new ExceptionRule();
+ private Optional acquire(String tld, Duration leaseLength) {
+ return Lock.acquire(RESOURCE_NAME, tld, leaseLength, requestStatusChecker);
+ }
+
+ @Before public void setUp() {
+ when(requestStatusChecker.getLogId()).thenReturn("current-request-id");
+ when(requestStatusChecker.isRunning("current-request-id")).thenReturn(true);
+ }
+
@Test
public void testReleasedExplicitly() throws Exception {
- Lock lock = Lock.acquire(RESOURCE_NAME, "", ONE_DAY);
- assertThat(lock).isNotNull();
+ Optional lock = acquire("", ONE_DAY);
+ assertThat(lock).isPresent();
// We can't get it again at the same time.
- assertThat(Lock.acquire(RESOURCE_NAME, "", ONE_DAY)).isNull();
+ assertThat(acquire("", ONE_DAY)).isAbsent();
// But if we release it, it's available.
- lock.release();
- assertThat(Lock.acquire(RESOURCE_NAME, "", ONE_DAY)).isNotNull();
+ lock.get().release();
+ assertThat(acquire("", ONE_DAY)).isPresent();
}
@Test
public void testReleasedAfterTimeout() throws Exception {
FakeClock clock = new FakeClock();
inject.setStaticField(Ofy.class, "clock", clock);
- assertThat(Lock.acquire(RESOURCE_NAME, "", TWO_MILLIS)).isNotNull();
+ assertThat(acquire("", TWO_MILLIS)).isPresent();
// We can't get it again at the same time.
- assertThat(Lock.acquire(RESOURCE_NAME, "", TWO_MILLIS)).isNull();
+ assertThat(acquire("", TWO_MILLIS)).isAbsent();
// A second later we still can't get the lock.
clock.advanceOneMilli();
- assertThat(Lock.acquire(RESOURCE_NAME, "", TWO_MILLIS)).isNull();
+ assertThat(acquire("", TWO_MILLIS)).isAbsent();
// But two seconds later we can get it.
clock.advanceOneMilli();
- assertThat(Lock.acquire(RESOURCE_NAME, "", TWO_MILLIS)).isNotNull();
+ assertThat(acquire("", TWO_MILLIS)).isPresent();
+ }
+
+ @Test
+ public void testReleasedAfterRequestFinish() throws Exception {
+ assertThat(acquire("", ONE_DAY)).isPresent();
+ // We can't get it again while request is active
+ assertThat(acquire("", ONE_DAY)).isAbsent();
+ // But if request is finished, we can get it.
+ when(requestStatusChecker.isRunning("current-request-id")).thenReturn(false);
+ assertThat(acquire("", ONE_DAY)).isPresent();
}
@Test
public void testTldsAreIndependent() throws Exception {
- Lock lockA = Lock.acquire(RESOURCE_NAME, "a", ONE_DAY);
- assertThat(lockA).isNotNull();
+ Optional lockA = acquire("a", ONE_DAY);
+ assertThat(lockA).isPresent();
// For a different tld we can still get a lock with the same name.
- Lock lockB = Lock.acquire(RESOURCE_NAME, "b", ONE_DAY);
- assertThat(lockB).isNotNull();
+ Optional lockB = acquire("b", ONE_DAY);
+ assertThat(lockB).isPresent();
// We can't get lockB again at the same time.
- assertThat(Lock.acquire(RESOURCE_NAME, "b", ONE_DAY)).isNull();
+ assertThat(acquire("b", ONE_DAY)).isAbsent();
// Releasing lockA has no effect on lockB (even though we are still using the "b" tld).
- lockA.release();
- assertThat(Lock.acquire(RESOURCE_NAME, "b", ONE_DAY)).isNull();
+ lockA.get().release();
+ assertThat(acquire("b", ONE_DAY)).isAbsent();
}
@Test
public void testFailure_emptyResourceName() throws Exception {
thrown.expect(IllegalArgumentException.class, "resourceName cannot be null or empty");
- Lock.acquire("", "", TWO_MILLIS);
+ Lock.acquire("", "", TWO_MILLIS, requestStatusChecker);
}
}
diff --git a/javatests/google/registry/request/lock/BUILD b/javatests/google/registry/request/lock/BUILD
new file mode 100644
index 000000000..ff87244dc
--- /dev/null
+++ b/javatests/google/registry/request/lock/BUILD
@@ -0,0 +1,36 @@
+package(
+ default_testonly = 1,
+ default_visibility = ["//java/google/registry:registry_project"],
+)
+
+licenses(["notice"]) # Apache 2.0
+
+load("//java/com/google/testing/builddefs:GenTestRules.bzl", "GenTestRules")
+
+java_library(
+ name = "lock",
+ srcs = glob(["*.java"]),
+ resources = glob(["testdata/*"]),
+ deps = [
+ "//java/google/registry/model",
+ "//java/google/registry/request/lock",
+ "//javatests/google/registry/testing",
+ "//third_party/java/objectify:objectify-v4_1",
+ "@com_google_appengine_api_1_0_sdk//:testonly",
+ "@com_google_appengine_tools_appengine_gcs_client",
+ "@com_google_appengine_tools_sdk",
+ "@com_google_code_findbugs_jsr305",
+ "@com_google_guava",
+ "@com_google_truth",
+ "@javax_servlet_api",
+ "@joda_time",
+ "@junit",
+ "@org_mockito_all",
+ ],
+)
+
+GenTestRules(
+ name = "GeneratedTestRules",
+ test_files = glob(["*Test.java"]),
+ deps = [":lock"],
+)
diff --git a/javatests/google/registry/request/lock/LockHandlerImplTest.java b/javatests/google/registry/request/lock/LockHandlerImplTest.java
new file mode 100644
index 000000000..8180f7922
--- /dev/null
+++ b/javatests/google/registry/request/lock/LockHandlerImplTest.java
@@ -0,0 +1,132 @@
+// Copyright 2017 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.request.lock;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.common.base.Optional;
+import google.registry.model.server.Lock;
+import google.registry.testing.AppEngineRule;
+import google.registry.testing.ExceptionRule;
+import java.util.concurrent.Callable;
+import javax.annotation.Nullable;
+import org.joda.time.Duration;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link LockHandler}. */
+@RunWith(JUnit4.class)
+public final class LockHandlerImplTest {
+
+ private static final Duration ONE_DAY = Duration.standardDays(1);
+
+ @Rule
+ public final AppEngineRule appEngine = AppEngineRule.builder()
+ .build();
+
+ @Rule
+ public final ExceptionRule thrown = new ExceptionRule();
+
+ private static class CountingCallable implements Callable {
+ int numCalled = 0;
+
+ @Override
+ public Void call() {
+ numCalled += 1;
+ return null;
+ }
+ }
+
+ private static class ThrowingCallable implements Callable {
+ Exception exception;
+
+ ThrowingCallable(Exception exception) {
+ this.exception = exception;
+ }
+
+ @Override
+ public Void call() throws Exception {
+ throw exception;
+ }
+ }
+
+ private boolean executeWithLocks(Callable callable, final @Nullable Lock acquiredLock) {
+ LockHandler lockHandler = new LockHandlerImpl() {
+ private static final long serialVersionUID = 0L;
+ @Override
+ Optional acquire(String resourceName, String tld, Duration leaseLength) {
+ assertThat(resourceName).isEqualTo("resourceName");
+ assertThat(tld).isEqualTo("tld");
+ assertThat(leaseLength).isEqualTo(ONE_DAY);
+ return Optional.fromNullable(acquiredLock);
+ }
+ };
+
+ return lockHandler.executeWithLocks(callable, "tld", ONE_DAY, "resourceName");
+ }
+
+ @Before public void setUp() {
+ }
+
+ @Test
+ public void testLockSucceeds() throws Exception {
+ Lock lock = mock(Lock.class);
+ CountingCallable countingCallable = new CountingCallable();
+ assertThat(executeWithLocks(countingCallable, lock)).isTrue();
+ assertThat(countingCallable.numCalled).isEqualTo(1);
+ verify(lock, times(1)).release();
+ }
+
+ @Test
+ public void testLockSucceeds_uncheckedException() throws Exception {
+ Lock lock = mock(Lock.class);
+ Exception expectedException = new RuntimeException("test");
+ try {
+ executeWithLocks(new ThrowingCallable(expectedException), lock);
+ fail("Expected RuntimeException");
+ } catch (RuntimeException exception) {
+ assertThat(exception).isSameAs(expectedException);
+ }
+ verify(lock, times(1)).release();
+ }
+
+ @Test
+ public void testLockSucceeds_checkedException() throws Exception {
+ Lock lock = mock(Lock.class);
+ Exception expectedException = new Exception("test");
+ try {
+ executeWithLocks(new ThrowingCallable(expectedException), lock);
+ fail("Expected RuntimeException");
+ } catch (RuntimeException exception) {
+ assertThat(exception).hasCauseThat().isSameAs(expectedException);
+ }
+ verify(lock, times(1)).release();
+ }
+
+ @Test
+ public void testLockFailed() throws Exception {
+ Lock lock = null;
+ CountingCallable countingCallable = new CountingCallable();
+ assertThat(executeWithLocks(countingCallable, lock)).isFalse();
+ assertThat(countingCallable.numCalled).isEqualTo(0);
+ }
+}