package com.ankurm.expandcontract; import com.ankurm.expandcontract.customer.CustomerService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.jdbc.core.simple.JdbcClient; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; /** * The finding that {@link com.ankurm.expandcontract.customer.CustomerService#withRetryForConcurrentDdl} * cannot fix, because nothing throws. The retry helper exists for the case where a concurrent * statement collides with an in-progress {@code ALTER TABLE} and H2 answers with * "table not found" - a real error the caller can see and retry. This test demonstrates a second, * stranger failure mode found the same way the first one was: by running this module's own load * generator against a live rolling deploy and noticing a handful of reads and updates come back * 404 for a customer id that a 201 response had already confirmed existed. *
* H2 implements both {@code ALTER TABLE ... ADD COLUMN} and {@code ALTER TABLE ... DROP COLUMN} * by rebuilding the table: copying every row into a new table with the new column layout and * swapping it in. If an ordinary {@code INSERT} on another connection commits - with no error, * with a generated key handed back to the caller - while that rebuild is in flight, the inserted * row can be copied into the new table or left behind in the old one depending on exactly when * the rebuild's internal scan ran relative to the commit. When it is left behind, the row is gone * the instant the rebuild finishes, and nothing on the inserting connection was ever told. *
* This is not how every database implements {@code ADD COLUMN} and {@code DROP COLUMN}. PostgreSQL's
* reference manual is explicit that both are metadata-only operations on tables like this one -
* no non-volatile default and no immediate space reclamation requested - so this is a property of
* H2's implementation, not of the expand-contract technique itself. See
* docs/14-the-ddl-lock-window.md for the full explanation, the standalone reproduction this test
* is built from, and what it implies for choosing a target database for a real migration.
*/
class DdlSilentDataLossTest {
// Whether the rebuild's internal scan actually passes a given row before or after that
// row's INSERT commits is OS scheduling, not application logic - so a single attempt at
// this race can genuinely land on either side of it. A single-attempt version of this test
// failed about 1 run in 5 while writing it. Rather than assert on one attempt (flaky either
// way) or loosen the assertion to "zero or more" (which would silently stop proving anything
// the day this stops reproducing), this test repeats the race, on a fresh table each time,
// until it reproduces - the same thing a human would do at a terminal to confirm a suspected
// race is real. Twenty attempts reproduced it within the first 4 in every run made while
// writing this test.
private static final int MAX_ATTEMPTS = 20;
@Test
void concurrentAlterTableAddColumnCanSilentlyDropAnAlreadyCommittedInsert(@TempDir Path tmp) throws Exception {
Transcript t = Transcript.start("14-ddl-silent-data-loss",
"The failure the retry cannot catch: a committed INSERT that ALTER TABLE loses silently");
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
Path db = tmp.resolve("silent-data-loss-" + attempt);
TestSupport.migrateTo(db, "1");
JdbcClient jdbc = TestSupport.jdbcClient(db);
CustomerService stage1 = new CustomerService(jdbc, 1);
AtomicBoolean stop = new AtomicBoolean(false);
List