Checks flyway deadlock risk for new schema chagnes (#2078)

* Checks flyway deadlock risk for new schema chagnes
This commit is contained in:
Weimin Yu 2023-07-26 14:35:48 -04:00 committed by GitHub
parent 9b17adcb28
commit 43d325d2a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 256 additions and 5 deletions

View file

@ -56,11 +56,23 @@ following steps:
table statements can be used as is, whereas alter table statements should be table statements can be used as is, whereas alter table statements should be
written to change any existing tables. written to change any existing tables.
Note that each incremental file MUST be limited to changes to a single If an incremental file changes more than one schema element (table, index,
table (otherwise it may hit deadlock when applying on sandbox/production or sequence), it MAY hit deadlocks when applied on sandbox/production where
where it'll be competing against live traffic that may also be locking said 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 elements but in a different order. The `FlywayDeadlockTest` checks for this
scripts in a single PR. 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
<a href="./src/test/java/google/registry/sql/flyway/FlywayDeadlockTest.java">
the test class's javadoc</a> 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 This script should be stored in a new file in the
`db/src/main/resources/sql/flyway` folder using the naming pattern `db/src/main/resources/sql/flyway` folder using the naming pattern

View file

@ -168,6 +168,7 @@ dependencies {
testRuntimeOnly deps['com.google.flogger:flogger-system-backend'] testRuntimeOnly deps['com.google.flogger:flogger-system-backend']
testImplementation deps['com.google.guava:guava'] testImplementation deps['com.google.guava:guava']
testImplementation deps['com.google.truth:truth'] 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'] 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-api']
testImplementation deps['org.junit.jupiter:junit-jupiter-engine'] testImplementation deps['org.junit.jupiter:junit-jupiter-engine']

View file

@ -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.j2objc:j2objc-annotations:2.8=testCompileClasspath,testRuntimeClasspath
com.google.oauth-client:google-oauth-client:1.34.1=default,deploy_jar,runtimeClasspath,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.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.google.truth:truth:1.1.3=testCompileClasspath,testRuntimeClasspath
com.googlecode.java-diff-utils:diffutils:1.3.0=annotationProcessor,errorprone,testAnnotationProcessor com.googlecode.java-diff-utils:diffutils:1.3.0=annotationProcessor,errorprone,testAnnotationProcessor
com.puppycrawl.tools:checkstyle:8.37=checkstyle com.puppycrawl.tools:checkstyle:8.37=checkstyle

View file

@ -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.
*
* <p>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.
*
* <p>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<Pattern, Integer> DDL_PATTERNS =
ImmutableMap.of(
Pattern.compile(
"^\\s*CREATE\\s+(UNIQUE\\s+)?INDEX\\s+"
+ "(IF\\s+NOT\\s+EXISTS\\s+)*(public.)?((\\w+)|(\"\\w+\"))\\s+ON\\s+(ONLY\\s+)?"
+ "(public.)?((\\w+)|(\"\\w+\"))[^;]+$",
CASE_INSENSITIVE),
9,
Pattern.compile(
"^\\s*ALTER\\s+INDEX\\s+(IF\\s+EXISTS\\s+)?(public.)?((\\w+)|(\"\\w+\"))[^;]+$",
CASE_INSENSITIVE),
3,
Pattern.compile(
"^\\s*ALTER\\s+SEQUENCE\\s+(IF\\s+EXISTS\\s+)?(public.)?((\\w+)|(\"\\w+\"))[^;]+$",
CASE_INSENSITIVE),
3,
Pattern.compile(
"^\\s*ALTER\\s+TABLE\\s+(IF\\s+EXISTS\\s+|ONLY\\s+)*(public.)?((\\w+)|(\"\\w+\"))[^;]+$",
CASE_INSENSITIVE),
3);
@Test
public void validateNewFlywayScripts() {
ImmutableList<Path> newScriptPaths = findChangedFlywayScripts();
ImmutableList<String> scriptAndLockedElements =
newScriptPaths.stream()
.filter(path -> parseDdlScript(path).size() > 1)
.map(path -> String.format("%s: %s", path, parseDdlScript(path)))
.collect(toImmutableList());
assertWithMessage("Scripts changing more than one schema elements:")
.that(scriptAndLockedElements)
.isEmpty();
}
@Test
public void testGetDdlLockedElementName_found() {
ImmutableList<String> ddls =
ImmutableList.of(
"alter table element_name ...",
"ALTER table if EXISTS ONLY \"element_name\" ...",
"alter index element_name \n...",
"Alter sequence public.\"element_name\" ...",
"create index if not exists \"index_name\" on element_name ...",
"create index if not exists \"index_name\" on public.\"element_name\" ...",
"create index if not exists index_name on public.element_name ...",
"create index if not exists \"index_name\" on public.element_name ...",
"create unique index public.index_name on public.\"element_name\" ...");
ddls.forEach(
ddl -> {
assertThat(getDdlLockedElementName(ddl)).hasValue("element_name");
});
}
@Test
public void testGetDdlLockedElementName_notFound() {
ImmutableList<String> ddls =
ImmutableList.of(
"create table element_name ...;",
"create sequence public.\"element_name\" ...;",
"create index concurrently if not exists index_name on public.element_name ...;",
"create unique index concurrently public.index_name on public.\"element_name\" ...;",
"drop table element_name ...;",
"drop sequence element_name ...;",
"drop INDEX element_name ...;");
ddls.forEach(
ddl -> {
assertThat(getDdlLockedElementName(ddl)).isEmpty();
});
}
static Optional<String> getDdlLockedElementName(String ddl) {
for (Map.Entry<Pattern, Integer> patternEntry : DDL_PATTERNS.entrySet()) {
Matcher matcher = patternEntry.getKey().matcher(ddl);
if (matcher.find()) {
String name = matcher.group(patternEntry.getValue());
return Optional.of(name.replace("\"", ""));
}
}
return Optional.empty();
}
static ImmutableSet<String> parseDdlScript(Path path) {
try {
return SQL_TEXT_SPLITTER
.splitToStream(
readAllLines(path, UTF_8).stream()
.map(line -> line.replaceAll("--.*", ""))
.filter(line -> !line.isBlank())
.collect(joining(" ")))
.map(FlywayDeadlockTest::getDdlLockedElementName)
.filter(Optional::isPresent)
.map(Optional::get)
.collect(toImmutableSet());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
static ImmutableList<Path> findChangedFlywayScripts() {
try {
String forkPoint;
try {
forkPoint = executeShellCommand(GIT_FORK_POINT_CMD);
} catch (RuntimeException e) {
if (e.getMessage() != null && e.getMessage().contains("not a git repository")) {
logger.atInfo().log("Not in git repo: probably the internal-pr or -ci test.");
return ImmutableList.of();
}
throw e;
}
String rootDir = executeShellCommand(GIT_GET_ROOT_DIR_CMD);
String changedScriptsCommand = String.format(GET_CHANGED_SCRIPTS_CMD, forkPoint);
ImmutableList<Path> changedPaths =
CHANGED_FILENAMES_SPLITTER
.splitToList(executeShellCommand(changedScriptsCommand, Optional.of(rootDir)))
.stream()
.map(pathStr -> rootDir + File.separator + pathStr)
.map(Path::of)
.collect(toImmutableList());
if (changedPaths.isEmpty()) {
logger.atInfo().log("There are no schema changes.");
} else {
logger.atInfo().log("Found %s new Flyway scripts", changedPaths.size());
}
return changedPaths;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
static String executeShellCommand(String command) throws IOException {
return executeShellCommand(command, Optional.empty());
}
static String executeShellCommand(String command, Optional<String> workingDir)
throws IOException {
ProcessBuilder processBuilder =
new ProcessBuilder(SHELL_COMMAND_SPLITTER.splitToList(command).toArray(new String[0]));
workingDir.map(File::new).ifPresent(processBuilder::directory);
Process process = processBuilder.start();
String output = new String(process.getInputStream().readAllBytes(), UTF_8);
String error = new String(process.getErrorStream().readAllBytes(), UTF_8);
try {
process.waitFor(1, SECONDS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
if (process.exitValue() != 0) {
throw new RuntimeException(error);
}
return output.trim();
}
}