Add db-migrations-expand-contract: zero-downtime schema migrations proven with a real 4-deploy rolling run

Companion code for Zero-Downtime Database Migrations: Expand-Contract in Practice
with Spring Boot: a full expand/migrate-writes/migrate-reads/contract sequence run
as an actual rolling deploy across two live replicas, with a load generator sending
continuous HTTP traffic through all four deploys (99.98% success, every residual
error traced to a root cause rather than left unexplained). Findings include a real
NOT NULL constraint trap in the expand migration, a backfill-window bug in the read
switch, H2's AUTO_SERVER=TRUE single-point-of-failure behavior under a rolling
restart, the drain-before-SIGTERM fix needed to close a health-check gap during
graceful shutdown, and H2 silently discarding a concurrently committed INSERT during
an ALTER TABLE ADD/DROP COLUMN rebuild - confirmed, by primary source, to be an
H2-specific behavior rather than a property of the technique itself.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Fb7vW8vLyLKngBc4R3huA
This commit is contained in:
2026-09-16 19:22:48 +00:00
co-authored by Claude Sonnet 5
parent 22c30d4a5b
commit e478eafda3
60 changed files with 3561 additions and 0 deletions
@@ -0,0 +1,72 @@
package com.ankurm.expandcontract;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/** Renders a JDBC query as a plain-text table for transcripts - no ORM, no formatting library. */
public final class DbDump {
private DbDump() {
}
public static String table(Connection conn, String sql) {
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
ResultSetMetaData meta = rs.getMetaData();
int cols = meta.getColumnCount();
List<String> headers = new ArrayList<>();
for (int i = 1; i <= cols; i++) {
headers.add(meta.getColumnLabel(i));
}
List<List<String>> rows = new ArrayList<>();
while (rs.next()) {
List<String> row = new ArrayList<>();
for (int i = 1; i <= cols; i++) {
Object v = rs.getObject(i);
row.add(v == null ? "NULL" : v.toString());
}
rows.add(row);
}
int[] widths = new int[cols];
for (int i = 0; i < cols; i++) {
widths[i] = headers.get(i).length();
}
for (List<String> row : rows) {
for (int i = 0; i < cols; i++) {
widths[i] = Math.max(widths[i], row.get(i).length());
}
}
StringBuilder sb = new StringBuilder();
appendRow(sb, headers, widths);
StringBuilder sep = new StringBuilder();
for (int i = 0; i < cols; i++) {
sep.append("-".repeat(widths[i])).append(i < cols - 1 ? "-+-" : "");
}
sb.append(sep).append(System.lineSeparator());
for (List<String> row : rows) {
appendRow(sb, row, widths);
}
sb.append("(").append(rows.size()).append(" row").append(rows.size() == 1 ? "" : "s").append(")");
return sb.toString();
}
catch (SQLException ex) {
return "query failed: " + ex.getMessage();
}
}
private static void appendRow(StringBuilder sb, List<String> values, int[] widths) {
for (int i = 0; i < values.size(); i++) {
sb.append(pad(values.get(i), widths[i]));
sb.append(i < values.size() - 1 ? " | " : "");
}
sb.append(System.lineSeparator());
}
private static String pad(String s, int width) {
return s + " ".repeat(Math.max(0, width - s.length()));
}
}