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: - --