Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
24eb254
Throw start up exception with custom message and stop server
TobyCyan Jun 15, 2026
460f1d1
Create dev server error handling system
TobyCyan Jun 15, 2026
ac3cff9
Rename package
TobyCyan Jun 15, 2026
8f9b533
Rename error handler list and make private
TobyCyan Jun 15, 2026
5e4037d
Merge branch 'master' into more-friendly-schema-validation-error
TobyCyan Jun 22, 2026
e4f4b8a
Handle exception instead of throwable
TobyCyan Jun 22, 2026
793df49
Merge branch 'master' into more-friendly-schema-validation-error
TobyCyan Jun 25, 2026
5f36b11
Commit suggestions
TobyCyan Jun 25, 2026
e0a02dc
Simplify error handling
TobyCyan Jun 25, 2026
10a0583
Remove package from testng
TobyCyan Jun 25, 2026
addee80
Fail fast based on liquibase status
TobyCyan Jun 29, 2026
e7718f1
Move packages into main
TobyCyan Jun 29, 2026
ce35882
Use liquibase API
TobyCyan Jun 29, 2026
f3b2c98
Move status checker to liquibase package
TobyCyan Jun 29, 2026
229170d
Use instanceof to determine exception
TobyCyan Jun 29, 2026
908a88f
Fix pmd
TobyCyan Jun 29, 2026
0f78125
Merge branch 'master' into more-friendly-schema-validation-error
TobyCyan Jun 29, 2026
231501d
Revert package comment
TobyCyan Jun 29, 2026
885715e
Merge branch 'master' into more-friendly-schema-validation-error
TobyCyan Jul 7, 2026
d3bf228
Upgrade liquibase version
TobyCyan Jul 7, 2026
553090d
Normalize changeset file paths
TobyCyan Jul 7, 2026
fafd51e
Remove liquibase core testImplementation dependency
TobyCyan Jul 7, 2026
fc63779
Extract transform start up exception
TobyCyan Jul 7, 2026
8d7190e
Merge branch 'master' into more-friendly-schema-validation-error
TobyCyan Jul 8, 2026
947dabf
Revert liquibase core version
TobyCyan Jul 8, 2026
717f6e6
Force commons-lang3 version
TobyCyan Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ buildscript {
exclude group: "org.gradle"
}
classpath "org.liquibase:liquibase-gradle-plugin:3.1.0"
classpath "org.liquibase:liquibase-core:4.27.0"
classpath "org.liquibase:liquibase-core:4.33.0"
}
}

configurations {
staticAnalysis

liquibaseRuntime.extendsFrom testImplementation
liquibaseRuntime.extendsFrom implementation
}


Expand All @@ -56,11 +56,13 @@ dependencies {
implementation("com.google.cloud:google-cloud-logging")
implementation("tools.jackson.core:jackson-databind:3.1.1")
implementation("commons-lang:commons-lang:2.6")
implementation("org.apache.commons:commons-lang3:3.18.0")
implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1")
implementation("com.helger:ph-commons:9.5.5") // necessary to add SpotBugs suppression
implementation("com.mailjet:mailjet-client:5.2.5")
implementation("com.sendgrid:sendgrid-java:4.10.3")
implementation(platform("org.eclipse.jetty:jetty-bom:11.0.20"))
implementation("org.liquibase:liquibase-core:4.33.0")
implementation("org.eclipse.jetty:jetty-slf4j-impl")
implementation("org.eclipse.jetty:jetty-server")
implementation("org.eclipse.jetty:jetty-webapp")
Expand All @@ -81,7 +83,6 @@ dependencies {
testImplementation("com.deque.html.axe-core:selenium:4.11.2")
testImplementation(testng)
testImplementation("org.testcontainers:testcontainers-postgresql:2.0.5")
testImplementation("org.liquibase:liquibase-core:4.27.0")
testImplementation("org.mockito:mockito-core:5.21.0")
// For supporting authorization code flow locally
testImplementation("com.google.oauth-client:google-oauth-client-jetty:1.39.0")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package teammates.common.exception;

/**
* Exception thrown when there are unapplied database migrations.
*/
public class PendingDatabaseMigrationsException extends Exception {

private final String migrationStatus;

public PendingDatabaseMigrationsException(String message, String migrationStatus) {
super(message);
this.migrationStatus = migrationStatus;
}

public String getMigrationStatus() {
return migrationStatus;
}

}
94 changes: 94 additions & 0 deletions src/main/java/teammates/liquibase/LiquibaseStatusChecker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package teammates.liquibase;

import java.util.List;
import java.util.Optional;

import teammates.common.exception.PendingDatabaseMigrationsException;
import teammates.common.util.Config;

import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.changelog.ChangeSet;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.exception.DatabaseException;
import liquibase.exception.LiquibaseException;
import liquibase.resource.ClassLoaderResourceAccessor;
import liquibase.resource.ResourceAccessor;

/**
* Checks whether the liquibase status is successful.
*/
public final class LiquibaseStatusChecker {

private static final String CHANGELOG_FILE = "db/changelog/db.changelog-root.xml";
private static final String GRADLE_CHANGELOG_FILE_PREFIX = "src/main/resources/";

private LiquibaseStatusChecker() {
// utility class
}

/**
* Asserts that liquibase reports successful status.
*/
public static void assertSuccessStatus() throws LiquibaseException, PendingDatabaseMigrationsException {
assertDatabaseUpToDate();
}

/**
* Fails fast when Liquibase reports unapplied changesets.
*/
private static void assertDatabaseUpToDate() throws LiquibaseException, PendingDatabaseMigrationsException {
Optional<String> pendingMigrationStatus = getPendingMigrationStatus();
if (pendingMigrationStatus.isEmpty()) {
return;
}

throw new PendingDatabaseMigrationsException(
"Database schema is out of date. Apply pending Liquibase migrations before starting the server.",
pendingMigrationStatus.get());
}

private static Optional<String> getPendingMigrationStatus() throws LiquibaseException {
List<ChangeSet> unrunChangeSets = listUnrunChangeSets();
return unrunChangeSets.isEmpty()
? Optional.empty()
: Optional.of(formatPendingMigrationStatus(unrunChangeSets));
}

private static List<ChangeSet> listUnrunChangeSets() throws LiquibaseException {
try (ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor();
Liquibase liquibase = createLiquibase(resourceAccessor)) {
normalizeChangeSetFilePaths(liquibase);
return liquibase.listUnrunChangeSets(new Contexts(), new LabelExpression());
} catch (Exception e) {
throw new LiquibaseException("Failed to check Liquibase status", e);
}
}

/**
* Normalizes file paths to match the paths used in the Gradle build,
* so that the unrun changesets can be correctly identified.
*/
private static void normalizeChangeSetFilePaths(Liquibase liquibase) throws LiquibaseException {
for (ChangeSet changeSet : liquibase.getDatabaseChangeLog().getChangeSets()) {
changeSet.setFilePath(GRADLE_CHANGELOG_FILE_PREFIX + changeSet.getFilePath());
}
}

private static Liquibase createLiquibase(ResourceAccessor resourceAccessor) throws DatabaseException {
Database database = DatabaseFactory.getInstance().openDatabase(
Config.getDbConnectionUrl(),
Config.POSTGRES_USERNAME,
Config.POSTGRES_PASSWORD,
null,
resourceAccessor);
return new Liquibase(CHANGELOG_FILE, resourceAccessor, database);
}

private static String formatPendingMigrationStatus(List<ChangeSet> unrunChangeSets) {
return unrunChangeSets.size() + " pending Liquibase changeset(s).";
}

}
29 changes: 28 additions & 1 deletion src/main/java/teammates/main/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import teammates.common.util.Config;
import teammates.common.util.Logger;
import teammates.liquibase.LiquibaseStatusChecker;
import teammates.main.util.DevServerStartupErrorHandler;
import teammates.ui.servlets.DevServerLoginServlet;

/**
Expand Down Expand Up @@ -75,12 +77,37 @@ public void lifeCycleStopped(LifeCycle event) {
server.setHandler(webapp);
server.setStopAtShutdown(true);
server.addEventListener(customLifeCycleListener);
webapp.setThrowUnavailableOnStartupException(true);

server.start();
// Fail fast checks.
try {
LiquibaseStatusChecker.assertSuccessStatus();
} catch (Exception e) {
throw transformStartupException(e);
}

try {
server.start();
} catch (Exception e) {
stopServer(server);
throw transformStartupException(e);
}

// By using the server.join() the server thread will join with the current thread.
// See https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.html#join-- for more details.
server.join();
}

private static void stopServer(Server server) {
try {
server.stop();
} catch (Exception e) {
log.severe("Failed to stop server after web application startup failure.", e);
}
}

private static Exception transformStartupException(Exception e) {
return Config.IS_DEV_SERVER ? DevServerStartupErrorHandler.transform(e) : e;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package teammates.main.exception;

/**
* Exception thrown when an error occurs during dev server startup.
*/
public class DevServerStartupException extends RuntimeException {

public DevServerStartupException(String message) {
// Null cause to avoid printing the stack trace twice.
super(message, null, true, false);
}

}
5 changes: 5 additions & 0 deletions src/main/java/teammates/main/exception/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

/**
* Contains exception classes for the main package.
*/
package teammates.main.exception;
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package teammates.main.util;

import java.util.List;
import java.util.Optional;
import java.util.function.Function;

import teammates.common.exception.PendingDatabaseMigrationsException;
import teammates.main.exception.DevServerStartupException;

/**
* Handles startup errors with dev-server-friendly messages where possible.
*/
public final class DevServerStartupErrorHandler {

private static final String SCHEMA_OUT_OF_DATE_MESSAGE = String.join(System.lineSeparator(),
"",
"============================================================",
"Database schema is not up to date:",
"%s",
"",
"Your local database schema is out of date.",
"Run: ./gradlew liquibaseUpdate",
"Then restart the local server.",
"",
"For more details, see docs/how-to/schema-migration.md",
"============================================================",
"");

/**
* A list of builders that can recognize startup errors and provide a dev-server-friendly message.
*/
private static final List<Function<Throwable, Optional<String>>> ERROR_MESSAGE_BUILDERS = List.of(
DevServerStartupErrorHandler::buildPendingDatabaseMigrationsMessage);

private DevServerStartupErrorHandler() {
// Utility class
}

/**
* Transforms a recognized startup error into a dev-server-friendly exception.
* Returns the original exception when no builder recognizes it.
*/
public static Exception transform(Exception e) {
return ERROR_MESSAGE_BUILDERS.stream()
.map(builder -> builder.apply(e))
.flatMap(optional -> optional.stream())
.findFirst()
.<Exception>map(message -> new DevServerStartupException(message))
.orElse(e);
}

/**
* Builds a dev-server-friendly message for a pending database migrations error.
*/
private static Optional<String> buildPendingDatabaseMigrationsMessage(Throwable error) {
if (!(error instanceof PendingDatabaseMigrationsException)) {
return Optional.empty();
}
PendingDatabaseMigrationsException pendingMigrationsFailure = (PendingDatabaseMigrationsException) error;
return Optional.of(String.format(SCHEMA_OUT_OF_DATE_MESSAGE, pendingMigrationsFailure.getMigrationStatus()));
}

}
4 changes: 4 additions & 0 deletions src/main/java/teammates/main/util/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Contains utility classes for the main package.
*/
package teammates.main.util;
Loading