diff --git a/core/src/main/java/google/registry/schema/server/Lock.java b/core/src/main/java/google/registry/schema/server/Lock.java
new file mode 100644
index 000000000..6ddf8f818
--- /dev/null
+++ b/core/src/main/java/google/registry/schema/server/Lock.java
@@ -0,0 +1,132 @@
+// Copyright 2020 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.schema.server;
+
+import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
+
+import google.registry.model.ImmutableObject;
+import google.registry.schema.server.Lock.LockId;
+import google.registry.util.DateTimeUtils;
+import java.io.Serializable;
+import java.time.ZonedDateTime;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.IdClass;
+import javax.persistence.Table;
+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
+ * {@link GLOBAL} is used.
+ *
+ *
This uses a compound primary key as defined in {@link LockId}.
+ */
+@Entity
+@Table
+@IdClass(LockId.class)
+public class Lock {
+
+ /** The resource name used to create the lock. */
+ @Column(nullable = false)
+ @Id
+ String resourceName;
+
+ /** The tld used to create the lock. */
+ @Column(nullable = false)
+ @Id
+ String tld;
+
+ /**
+ * 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.
+ */
+ @Column(nullable = false)
+ String requestLogId;
+
+ /** When the lock was acquired. Used for logging. */
+ @Column(nullable = false)
+ ZonedDateTime acquiredTime;
+
+ /** When the lock can be considered implicitly released. */
+ @Column(nullable = false)
+ ZonedDateTime expirationTime;
+
+ /** The scope of a lock that is not specific to a single tld. */
+ static final String GLOBAL = "GLOBAL";
+
+ /**
+ * Validate input and create a new {@link Lock} for the given resource name in the specified tld.
+ */
+ private Lock(
+ String resourceName,
+ String tld,
+ String requestLogId,
+ DateTime acquiredTime,
+ Duration leaseLength) {
+ this.resourceName = checkArgumentNotNull(resourceName, "The resource name cannot be null");
+ this.tld = checkArgumentNotNull(tld, "The tld cannot be null. For a global lock, use GLOBAL");
+ this.requestLogId =
+ checkArgumentNotNull(requestLogId, "The requestLogId of the lock cannot be null");
+ this.acquiredTime =
+ DateTimeUtils.toZonedDateTime(
+ checkArgumentNotNull(acquiredTime, "The acquired time of the lock cannot be null"));
+ checkArgumentNotNull(leaseLength, "The lease length of the lock cannot be null");
+ this.expirationTime = DateTimeUtils.toZonedDateTime(acquiredTime.plus(leaseLength));
+ }
+
+ // Hibernate requires a default constructor.
+ private Lock() {}
+
+ /** Constructs a {@link Lock} object. */
+ public static Lock create(
+ String resourceName,
+ String tld,
+ String requestLogId,
+ DateTime acquiredTime,
+ Duration leaseLength) {
+ checkArgumentNotNull(
+ tld, "The tld cannot be null. To create a global lock, use the createGlobal method");
+ return new Lock(resourceName, tld, requestLogId, acquiredTime, leaseLength);
+ }
+
+ /** Constructs a {@link Lock} object with a {@link GLOBAL} scope. */
+ public static Lock createGlobal(
+ String resourceName, String requestLogId, DateTime acquiredTime, Duration leaseLength) {
+ return new Lock(resourceName, GLOBAL, requestLogId, acquiredTime, leaseLength);
+ }
+
+ static class LockId extends ImmutableObject implements Serializable {
+
+ String resourceName;
+
+ String tld;
+
+ private LockId() {}
+
+ LockId(String resourceName, String tld) {
+ this.resourceName = checkArgumentNotNull(resourceName, "The resource name cannot be null");
+ this.tld = tld;
+ }
+ }
+}
diff --git a/core/src/main/java/google/registry/schema/server/LockDao.java b/core/src/main/java/google/registry/schema/server/LockDao.java
new file mode 100644
index 000000000..2ac8e3c62
--- /dev/null
+++ b/core/src/main/java/google/registry/schema/server/LockDao.java
@@ -0,0 +1,76 @@
+// Copyright 2020 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.schema.server;
+
+import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
+import static google.registry.schema.server.Lock.GLOBAL;
+import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
+
+import google.registry.schema.server.Lock.LockId;
+import java.util.Optional;
+
+/** Data access object class for {@link Lock}. */
+public class LockDao {
+
+ /** Saves the {@link Lock} object to Cloud SQL. */
+ public static void saveNew(Lock lock) {
+ jpaTm()
+ .transact(
+ () -> {
+ jpaTm().getEntityManager().persist(lock);
+ });
+ }
+
+ /**
+ * Loads and returns a {@link Lock} object with the given resourceName and tld from Cloud SQL if
+ * it exists, else empty.
+ */
+ public static Optional load(String resourceName, String tld) {
+ checkArgumentNotNull(resourceName, "The resource name of the lock to load cannot be null");
+ checkArgumentNotNull(tld, "The tld of the lock to load cannot be null");
+ return Optional.ofNullable(
+ jpaTm()
+ .transact(
+ () -> jpaTm().getEntityManager().find(Lock.class, new LockId(resourceName, tld))));
+ }
+
+ /**
+ * Loads a global {@link Lock} object with the given resourceName from Cloud SQL if it exists,
+ * else empty.
+ */
+ public static Optional load(String resourceName) {
+ checkArgumentNotNull(resourceName, "The resource name of the lock to load cannot be null");
+ return Optional.ofNullable(
+ jpaTm()
+ .transact(
+ () ->
+ jpaTm().getEntityManager().find(Lock.class, new LockId(resourceName, GLOBAL))));
+ }
+
+ /**
+ * Deletes the given {@link Lock} object from Cloud SQL. This method is idempotent and will simply
+ * return if the lock has already been deleted.
+ */
+ public static void delete(Lock lock) {
+ jpaTm()
+ .transact(
+ () -> {
+ Optional loadedLock = load(lock.resourceName, lock.tld);
+ if (loadedLock.isPresent()) {
+ jpaTm().getEntityManager().remove(loadedLock.get());
+ }
+ });
+ }
+}
diff --git a/core/src/main/resources/META-INF/persistence.xml b/core/src/main/resources/META-INF/persistence.xml
index 6d9401e46..17eef7e05 100644
--- a/core/src/main/resources/META-INF/persistence.xml
+++ b/core/src/main/resources/META-INF/persistence.xml
@@ -25,6 +25,7 @@
google.registry.schema.domain.RegistryLock
google.registry.schema.tmch.ClaimsList
google.registry.schema.cursor.Cursor
+ google.registry.schema.server.Lock
google.registry.schema.tld.PremiumList
google.registry.schema.tld.PremiumEntry
google.registry.schema.tld.ReservedList
diff --git a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java
index dcfa32e1d..038541a8a 100644
--- a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java
+++ b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java
@@ -19,6 +19,7 @@ import google.registry.model.domain.DomainBaseSqlTest;
import google.registry.model.registry.RegistryLockDaoTest;
import google.registry.persistence.transaction.JpaEntityCoverage;
import google.registry.schema.cursor.CursorDaoTest;
+import google.registry.schema.server.LockDaoTest;
import google.registry.schema.tld.PremiumListDaoTest;
import google.registry.schema.tld.ReservedListDaoTest;
import google.registry.schema.tmch.ClaimsListDaoTest;
@@ -56,6 +57,7 @@ import org.junit.runners.Suite.SuiteClasses;
CreateReservedListCommandTest.class,
CursorDaoTest.class,
DomainLockUtilsTest.class,
+ LockDaoTest.class,
LockDomainCommandTest.class,
DomainBaseSqlTest.class,
PremiumListDaoTest.class,
diff --git a/core/src/test/java/google/registry/schema/server/LockDaoTest.java b/core/src/test/java/google/registry/schema/server/LockDaoTest.java
new file mode 100644
index 000000000..478c5009c
--- /dev/null
+++ b/core/src/test/java/google/registry/schema/server/LockDaoTest.java
@@ -0,0 +1,129 @@
+// Copyright 2020 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.schema.server;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import google.registry.persistence.transaction.JpaTestRules;
+import google.registry.persistence.transaction.JpaTestRules.JpaIntegrationWithCoverageRule;
+import google.registry.testing.FakeClock;
+import java.util.Optional;
+import javax.persistence.RollbackException;
+import org.joda.time.Duration;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link Lock}. */
+@RunWith(JUnit4.class)
+public class LockDaoTest {
+
+ private final FakeClock fakeClock = new FakeClock();
+
+ @Rule
+ public final JpaIntegrationWithCoverageRule jpaRule =
+ new JpaTestRules.Builder().withClock(fakeClock).buildIntegrationWithCoverageRule();
+
+ @Test
+ public void save_worksSuccessfully() {
+ Lock lock =
+ Lock.create("testResource", "tld", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Optional returnedLock = LockDao.load("testResource", "tld");
+ assertThat(returnedLock.get().expirationTime).isEqualTo(lock.expirationTime);
+ assertThat(returnedLock.get().requestLogId).isEqualTo(lock.requestLogId);
+ }
+
+ @Test
+ public void save_failsWhenLockAlreadyExists() {
+ Lock lock =
+ Lock.create("testResource", "tld", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Lock lock2 =
+ Lock.create("testResource", "tld", "testLogId2", fakeClock.nowUtc(), Duration.millis(4));
+ RollbackException thrown = assertThrows(RollbackException.class, () -> LockDao.saveNew(lock2));
+ assertThat(thrown.getCause().getCause().getCause().getMessage())
+ .contains("duplicate key value violates unique constraint");
+ }
+
+ @Test
+ public void save_worksSuccesfullyGlobalLock() {
+ Lock lock =
+ Lock.createGlobal("testResource", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Optional returnedLock = LockDao.load("testResource");
+ assertThat(returnedLock.get().expirationTime).isEqualTo(lock.expirationTime);
+ assertThat(returnedLock.get().requestLogId).isEqualTo(lock.requestLogId);
+ }
+
+ @Test
+ public void load_worksSuccessfully() {
+ Lock lock =
+ Lock.create("testResource", "tld", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Optional returnedLock = LockDao.load("testResource", "tld");
+ assertThat(returnedLock.get().expirationTime).isEqualTo(lock.expirationTime);
+ assertThat(returnedLock.get().requestLogId).isEqualTo(lock.requestLogId);
+ }
+
+ @Test
+ public void load_worksSuccessfullyGlobalLock() {
+ Lock lock =
+ Lock.createGlobal("testResource", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Optional returnedLock = LockDao.load("testResource");
+ assertThat(returnedLock.get().expirationTime).isEqualTo(lock.expirationTime);
+ assertThat(returnedLock.get().requestLogId).isEqualTo(lock.requestLogId);
+ }
+
+ @Test
+ public void load_worksSuccesfullyLockDoesNotExist() {
+ Optional returnedLock = LockDao.load("testResource", "tld");
+ assertThat(returnedLock.isPresent()).isFalse();
+ }
+
+ @Test
+ public void delete_worksSuccesfully() {
+ Lock lock =
+ Lock.create("testResource", "tld", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Optional returnedLock = LockDao.load("testResource", "tld");
+ assertThat(returnedLock.get().expirationTime).isEqualTo(lock.expirationTime);
+ LockDao.delete(lock);
+ returnedLock = LockDao.load("testResource", "tld");
+ assertThat(returnedLock.isPresent()).isFalse();
+ }
+
+ @Test
+ public void delete_worksSuccessfullyGlobalLock() {
+ Lock lock =
+ Lock.createGlobal("testResource", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.saveNew(lock);
+ Optional returnedLock = LockDao.load("testResource");
+ assertThat(returnedLock.get().expirationTime).isEqualTo(lock.expirationTime);
+ LockDao.delete(lock);
+ returnedLock = LockDao.load("testResource");
+ assertThat(returnedLock.isPresent()).isFalse();
+ }
+
+ @Test
+ public void delete_succeedsLockDoesntExist() {
+ Lock lock =
+ Lock.createGlobal("testResource", "testLogId", fakeClock.nowUtc(), Duration.millis(2));
+ LockDao.delete(lock);
+ }
+}
diff --git a/db/src/main/resources/sql/flyway/V18__create_lock.sql b/db/src/main/resources/sql/flyway/V18__create_lock.sql
new file mode 100644
index 000000000..afe2dfdac
--- /dev/null
+++ b/db/src/main/resources/sql/flyway/V18__create_lock.sql
@@ -0,0 +1,22 @@
+ -- Copyright 2020 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.
+
+ create table "Lock" (
+ resource_name text not null,
+ tld text not null,
+ acquired_time timestamptz not null,
+ expiration_time timestamptz not null,
+ request_log_id text not null,
+ primary key (resource_name, tld)
+ );
diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated
index c57b245fb..903bb42d0 100644
--- a/db/src/main/resources/sql/schema/db-schema.sql.generated
+++ b/db/src/main/resources/sql/schema/db-schema.sql.generated
@@ -77,6 +77,15 @@
primary key (id)
);
+ create table "Lock" (
+ resource_name text not null,
+ tld text not null,
+ acquired_time timestamptz not null,
+ expiration_time timestamptz not null,
+ request_log_id text not null,
+ primary key (resource_name, tld)
+ );
+
create table "PremiumEntry" (
revision_id int8 not null,
domain_label text not null,
diff --git a/db/src/main/resources/sql/schema/nomulus.golden.sql b/db/src/main/resources/sql/schema/nomulus.golden.sql
index feb1f2f6b..9ac62b4b1 100644
--- a/db/src/main/resources/sql/schema/nomulus.golden.sql
+++ b/db/src/main/resources/sql/schema/nomulus.golden.sql
@@ -116,6 +116,19 @@ CREATE TABLE public."Domain" (
);
+--
+-- Name: Lock; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public."Lock" (
+ resource_name text NOT NULL,
+ tld text NOT NULL,
+ acquired_time timestamp with time zone NOT NULL,
+ expiration_time timestamp with time zone NOT NULL,
+ request_log_id text NOT NULL
+);
+
+
--
-- Name: PremiumEntry; Type: TABLE; Schema: public; Owner: -
--
@@ -374,6 +387,14 @@ ALTER TABLE ONLY public."Domain"
ADD CONSTRAINT "Domain_pkey" PRIMARY KEY (repo_id);
+--
+-- Name: Lock Lock_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public."Lock"
+ ADD CONSTRAINT "Lock_pkey" PRIMARY KEY (resource_name, tld);
+
+
--
-- Name: PremiumEntry PremiumEntry_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--