diff --git a/db/README.md b/db/README.md index a3c86157c..dc673cd4e 100644 --- a/db/README.md +++ b/db/README.md @@ -56,11 +56,23 @@ following steps: table statements can be used as is, whereas alter table statements should be written to change any existing tables. - Note that each incremental file MUST be limited to changes to a single - table (otherwise it may hit deadlock when applying on sandbox/production - where it'll be competing against live traffic that may also be locking said - tables but in a different order). It's OK to include these separate Flyway - scripts in a single PR. + If an incremental file changes more than one schema element (table, index, + or sequence), it MAY hit deadlocks when applied on sandbox/production where + it'll be competing against live traffic that may also be locking said + elements but in a different order. The `FlywayDeadlockTest` checks for this + risk for every new incremental file to be merged. Simply put, the test + treats any of the following as a changed element, and raises an error if a + new file has more than one changed elements: + + * A schema element (table, index, or sequence) being altered. + * The table on which an index is created without the `concurrently` + modifier. Please refer to + + the test class's javadoc for more information. + + Any file failing this test should be split up according to the error + message. It's OK to include these separate Flyway scripts in a single PR. + This script should be stored in a new file in the `db/src/main/resources/sql/flyway` folder using the naming pattern diff --git a/db/build.gradle b/db/build.gradle index 1c53b0cf1..22f83229c 100644 --- a/db/build.gradle +++ b/db/build.gradle @@ -168,6 +168,7 @@ dependencies { testRuntimeOnly deps['com.google.flogger:flogger-system-backend'] testImplementation deps['com.google.guava:guava'] testImplementation deps['com.google.truth:truth'] + testImplementation deps['com.google.truth.extensions:truth-java8-extension'] testRuntimeOnly deps['io.github.java-diff-utils:java-diff-utils'] testImplementation deps['org.junit.jupiter:junit-jupiter-api'] testImplementation deps['org.junit.jupiter:junit-jupiter-engine'] diff --git a/db/gradle.lockfile b/db/gradle.lockfile index cc9b26e16..90e253354 100644 --- a/db/gradle.lockfile +++ b/db/gradle.lockfile @@ -53,6 +53,7 @@ com.google.j2objc:j2objc-annotations:1.3=checkstyle,default,deploy_jar,runtimeCl com.google.j2objc:j2objc-annotations:2.8=testCompileClasspath,testRuntimeClasspath com.google.oauth-client:google-oauth-client:1.34.1=default,deploy_jar,runtimeClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:3.4.0=annotationProcessor,errorprone,testAnnotationProcessor +com.google.truth.extensions:truth-java8-extension:1.1.3=testCompileClasspath,testRuntimeClasspath com.google.truth:truth:1.1.3=testCompileClasspath,testRuntimeClasspath com.googlecode.java-diff-utils:diffutils:1.3.0=annotationProcessor,errorprone,testAnnotationProcessor com.puppycrawl.tools:checkstyle:8.37=checkstyle diff --git a/db/src/test/java/google/registry/sql/flyway/FlywayDeadlockTest.java b/db/src/test/java/google/registry/sql/flyway/FlywayDeadlockTest.java new file mode 100644 index 000000000..725664567 --- /dev/null +++ b/db/src/test/java/google/registry/sql/flyway/FlywayDeadlockTest.java @@ -0,0 +1,237 @@ +// Copyright 2023 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.sql.flyway; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.common.truth.Truth.assertWithMessage; +import static com.google.common.truth.Truth8.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.Files.readAllLines; +import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.regex.Pattern.CASE_INSENSITIVE; +import static java.util.stream.Collectors.joining; + +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.flogger.FluentLogger; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Checks if new Flyway scripts may cause Database deadlock. + * + *
Flyway deploys each DDL script in one transaction. If a script modifies multiple schema + * elements (table, index, sequence), it MAY hit deadlocks when applied on sandbox/production where + * it'll be competing against live traffic that may also be locking said elements but in a different + * order. This test checks every new script to be merged, counts the elements it locks, and raise an + * error when there are more than one locked elements. + * + *
For deadlock-prevention purpose, we can ignore elements being created or dropped: our
+ * schema-server compatibility tests ensure that such elements are not accessed by live traffic.
+ * Therefore, we focus on 'alter' statements. However, 'create index' is a special case: if the
+ * 'concurrently' modifier is not present, the indexed table is locked.
+ */
+public class FlywayDeadlockTest {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ // Relative path (from the root of the repository) of the scripts directory.
+ private static final String FLYWAY_SCRIPTS_DIR = "db/src/main/resources/sql/flyway";
+
+ // For splitting a shell command into an array of strings.
+ private static final Splitter SHELL_COMMAND_SPLITTER = Splitter.on(' ').trimResults();
+
+ // For splitting a multi-statement SQL string into individual statements.
+ private static final Splitter SQL_TEXT_SPLITTER =
+ Splitter.on(";").trimResults().omitEmptyStrings();
+
+ // For splitting the multi-line text containing all new script paths.
+ private static final Splitter CHANGED_FILENAMES_SPLITTER =
+ Splitter.on('\n').trimResults().omitEmptyStrings();
+
+ // Command that returns the git repo's root dir when executed anywhere in the repo.
+ private static final String GIT_GET_ROOT_DIR_CMD = "git rev-parse --show-toplevel";
+
+ // Returns the commit hash when the current branch branches of the main branch.
+ private static final String GIT_FORK_POINT_CMD = "git merge-base origin/master HEAD";
+
+ // Command template to get changed Flyways scripts, with fork-point to be filled in. This command
+ // is executed at the root dir of the repo. Any path returned is relative to the root dir.
+ private static final String GET_CHANGED_SCRIPTS_CMD =
+ "git diff %s --name-only " + FLYWAY_SCRIPTS_DIR;
+
+ // Map of DDL patterns and the capture group index of the element name in it.
+ public static final ImmutableMap