diff --git a/.github/workflows/mysql-correctness.yml b/.github/workflows/mysql-correctness.yml
new file mode 100644
index 00000000..51e093be
--- /dev/null
+++ b/.github/workflows/mysql-correctness.yml
@@ -0,0 +1,38 @@
+name: MySQL correctness
+
+on:
+ pull_request:
+ branches: [DevOps]
+ paths:
+ - 'src/**'
+ - 'pom.xml'
+ - 'sql/performance/**'
+ - 'scripts/performance/**'
+ - '.github/workflows/mysql-correctness.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ mysql-correctness:
+ runs-on: ubuntu-22.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: maven
+ - name: Real MySQL equivalence and rollback (no latency threshold)
+ run: mvn -B -Pmysql-performance verify -Dperf.accounts=10000 -Dperf.transactions=50000 -Dperf.samples=5
+ - name: Archive test reports, SQL, indexes and query plans
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: mysql-correctness-evidence
+ path: |
+ target/surefire-reports/
+ target/failsafe-reports/
+ target/mysql-performance/
+ retention-days: 14
diff --git a/README.md b/README.md
index 2f49958e..894c5d4f 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,8 @@
## End-to-End Bank Application Deployment using DevSecOps on AWS EKS
+
+For isolated local/CI testing without deployment, see the [MySQL query benchmark,
+correctness tests and reversible index migration](docs/mysql-performance.md).
+
- This is a multi-tier bank an application written in Java (Springboot).

diff --git a/docs/mysql-performance.md b/docs/mysql-performance.md
new file mode 100644
index 00000000..cfc031ea
--- /dev/null
+++ b/docs/mysql-performance.md
@@ -0,0 +1,258 @@
+# MySQL username lookup performance
+
+## Diagnosis and change
+
+`AccountRepository.findByUsername` is used by authentication, registration,
+dashboard, transaction history, and transfer recipient lookup. Hibernate emits:
+
+```sql
+select a1_0.id,a1_0.balance,a1_0.password,a1_0.username
+from account a1_0 where a1_0.username=?;
+```
+
+The actual MySQL schema generated from the `DevOps` entities had only `PRIMARY(id)`
+on `account`. It scanned every account even for a missing username. The change is
+one **non-unique**, full-column B-tree index on `account(username)`, in
+[`V001__account_username_index.sql`](../sql/performance/V001__account_username_index.sql).
+No application Java, repository query, controller, security configuration, template,
+database engine, response size, or ordering clause changes.
+
+The second candidate, `TransactionRepository.findByAccountId`, already uses the
+InnoDB-created foreign-key index `FK6g20fcr3bhr6bihgy24rq1r1b(account_id)`.
+`SHOW CREATE TABLE`, `SHOW INDEX`, and `EXPLAIN ANALYZE` verified it in the actual
+disposable database. Do **not** add a redundant transaction index. The generated
+history SQL is:
+
+```sql
+select t1_0.id,t1_0.account_id,t1_0.amount,t1_0.timestamp,t1_0.type
+from transaction t1_0 where t1_0.account_id=?;
+```
+
+Hibernate also loads the eager account by primary key when needed. That query was
+captured but not changed. History has no `ORDER BY`; this change introduces none,
+does not remove duplicates, and does not paginate. Observed row sequence is checked
+on the fixture, but unordered SQL does not guarantee a sequence across environments.
+
+## Reproduce
+
+Requirements: Java 17, Maven, a running local Docker daemon, and access to Maven
+Central and Docker Hub. No external database, credentials, or H2 substitutes are
+used by the integration test. Testcontainers creates MySQL **8.0.40**, binds its
+random port to loopback, generates a disposable password at runtime, and removes
+the container at JVM exit. Kubernetes specifies MySQL 8.0 and the application uses
+the MySQL 8 dialect; the root Compose/Helm `latest` tags are not used or changed.
+
+From the repository root:
+
+```bash
+# 100,000 accounts, 1,000,000 generated transactions + 6 boundary rows;
+# 5 warm-ups, then 21 measured samples per query per phase.
+scripts/performance/benchmark.sh
+
+# Smaller correctness fixture: entry point for an existing Jenkins job.
+mvn -B -Pmysql-performance verify \
+ -Dperf.accounts=10000 -Dperf.transactions=50000 -Dperf.samples=5
+
+# Configurable volume and repetitions (same before/after/rollback data).
+PERF_ACCOUNTS=25000 PERF_TRANSACTIONS=250000 PERF_SAMPLES=11 \
+ scripts/performance/benchmark.sh
+```
+
+The script uses installed `mvn`; `./mvnw` is an equivalent entry point once its
+Maven distribution is available. On the measurement VM, Java 17 was selected with
+`JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`. Maven Central returned HTTP 429;
+the local invocation appended `-s /home/ubuntu/maven-settings.xml`, a VM-only
+settings file mirroring `central` to
+`https://maven-central.storage-download.googleapis.com/maven2`. This settings file
+is not needed on normal runners and contains no credentials.
+
+`perf.accounts` accepts 100–1,000,000; `perf.transactions` accepts 1,000–5,000,000;
+`perf.samples` accepts 3–101; `perf.warmup` accepts 1–20. The default Maven fixture
+is 10,000 accounts / 50,000 transactions / 11 samples / 3 warm-ups. To retain
+separate local runs, append `-Dperf.output=target/mysql-ci` (or another output
+directory) and copy the JUnit reports before the next run. Do not run two Maven
+processes in the same checkout/output directory concurrently.
+
+### Data and measurement method
+
+The generator is deterministic, uses fixed dates and integer arithmetic, and
+preserves the mapped `DECIMAL(38,2)`, `DATETIME(6)`, nullable fields and MySQL
+`utf8mb4_0900_ai_ci` collation. Password fields contain 60 non-authenticating
+filler characters, not usable passwords. Balances are synthetic, not a reconciled
+ledger. There are deposits, withdrawals and both transfer labels, repeated values
+and timestamps, and six explicit boundary rows. The large fixture has:
+
+- 100,000 accounts, 80,000 with history, 1,000,006 total transaction rows;
+- one high-volume account with 50,000 rows (5% of generated transactions);
+- another 75% distributed across the remainder of the busiest 10% of accounts;
+- 20% across the next 70% of accounts; the rest are inactive;
+- duplicate usernames, a null username/balance, empty and 255-character usernames,
+ accent/case comparisons, duplicate monetary/timestamp rows, null fields and
+ null foreign keys, zero, negative cents, maximum decimals, leap day with
+ microseconds, and 1970/2038 timestamp boundaries.
+
+Each run creates the unchanged entity schema, captures generated SQL, inspects
+indexes, seeds once, and runs **before → add index → after → drop index → rollback**.
+It re-applies the index and verifies its presence at the end. All phases use the
+same server, connection, dataset, resource limits, predicates and complete result
+sets. `ANALYZE TABLE` runs in each phase. Each query receives its own warm-up; there
+is no artificial cold-cache baseline. No concurrent benchmark workload runs.
+
+Three timing types are kept separate:
+
+1. **Database execution:** the last-row time at the root iterator of repeated
+ `EXPLAIN ANALYZE` calls. This includes MySQL's instrumentation overhead, not
+ network transfer, Hibernate hydration, or rendering.
+2. **JDBC:** wall-clock preparation, execution, transfer and consumption of every
+ column of every row on a reused connection. This is *not* HTTP latency.
+3. **Application:** in-process MockMvc request/response wall time through the
+ security filter, controller, repository and Thymeleaf renderer, using a reused
+ synthetic authenticated identity. It excludes TCP, browser rendering and
+ password hashing; it is *not* end-user login latency. `/transactions` here uses
+ an ordinary account, not a truncated high-volume response.
+
+The control history query returns all 50,000 rows. Data digests are calculated
+outside timed samples. The test records raw samples and medians, not just the
+best run. The reverse/rollback phase helps expose warm-cache-only explanations;
+it is not a randomized load test. There are no wall-clock pass/fail thresholds.
+
+## Recorded local result
+
+MySQL 8.0.40 image ID
+`sha256:d58ac93387f644e4e040c636b8f50494e78e5afc27ca0a87348b2f577da2b7ff`,
+InnoDB, repeatable read, 256 MiB buffer pool. MySQL container limited to 2 CPUs /
+2 GiB RAM. Host: Linux x86-64, Intel Xeon, 8 visible CPUs, approximately 31 GiB RAM,
+no swap; Java 17.0.19, JVM maximum heap 8,413,773,824 bytes. One client, warmed
+dataset; 21 samples after 5 warm-ups per query in each phase.
+
+| Query / parameter | Rows | DB before → after (ms) | JDBC before → after (ms) | DB after rollback (ms) |
+|---|---:|---:|---:|---:|
+| Username / `user-0050000` | 1 | 22.3 → 0.00719 | 17.574 → 0.347 | 23.8 |
+| Username / `absent-user` | 0 | 19.0 → 0.00247 | 14.224 → 0.151 | 37.0 |
+| Username / `duplicate` | 2 | 19.1 → 0.00543 | 14.169 → 0.190 | 19.0 |
+| History / account `1` | 50,000 | 42.3 → 43.1 | 72.247 → 68.981 | 41.8 |
+
+Username lookup has a demonstrated improvement; history SQL has **no meaningful
+speedup** and is unchanged. Instrumented microsecond values should not be treated
+as precise production latency forecasts. The existing-username JDBC median fell
+about 51-fold; that is not an application-wide speedup claim.
+
+Separately, MockMvc response medians were `/dashboard` **35.620 → 2.432 ms** and
+`/transactions` **37.652 → 3.655 ms** for account `user-0050000`. The latter benefits
+from the initial username lookup, not a faster transaction query. JVM warm-up and
+scheduling noise also affect these in-process measurements.
+
+Representative execution shapes (full plans, including estimates/actual times,
+are archived by the harness):
+
+```text
+Before: Filter username = 'user-0050000' (rows=1)
+ Table scan on account (actual rows=100000 loops=1)
+After: Index lookup using idx_account_username (actual rows=1 loops=1)
+Rollback: Table scan on account again (actual rows=100000 loops=1)
+History in all phases: Index lookup using FK6g20fcr3bhr6bihgy24rq1r1b
+ (account_id=1; actual rows=50000 loops=1)
+```
+
+## Correctness and artifacts
+
+`MySqlPerformanceIT` is one end-to-end JUnit integration scenario with many
+assertions. It compares full ordered row digests and counts for all queried columns
+before/after/rollback, including empty, duplicate, collation, null and boundary
+cases, and checks the real Spring Data repositories in both index states.
+Duplicate usernames still cause `IncorrectResultSizeDataAccessException`; the
+index is deliberately **not unique**. High-volume history retains all rows,
+owner IDs, decimals and timestamps. A complete valid history HTML response is
+compared before/after. Null transaction fields are tested at SQL/repository level,
+not in the browser renderer.
+
+MockMvc checks anonymous redirects, ignores a supplied foreign `accountId` in
+favor of the signed-in username, verifies successful transfer balances and debit /
+credit rows, insufficient funds, and missing recipients, in both index states.
+The existing H2 context smoke test also runs but is **not MySQL correctness or
+performance evidence**. Browser verification is separate and recorded on the PR;
+it uses synthetic signed-in accounts and the applied index.
+
+Artifacts in `target/mysql-performance/`: captured Hibernate SQL, parameters and
+full `EXPLAIN ANALYZE` plans for each phase/case, actual schemas/indexes/table sizes,
+database and host metadata, all timing samples, medians, and `equivalence.txt`
+(written only after all comparisons and rollback/reapply succeed). JUnit reports
+are in `target/surefire-reports/` and `target/failsafe-reports/`. Preserve these
+directories before another run. Only a successful current Maven run and its
+equivalence marker are evidence of a pass; a failed run can leave partial plans.
+
+The new pull-request-only GitHub Actions job runs real MySQL in Testcontainers
+with the smaller fixture and uploads all three directories even on failure.
+It has read-only repository permissions, no publishing/deployment step, and no
+performance threshold. The existing Jenkins and deployment files are unchanged
+and were not invoked. A maintainer can use this test-only stage in an appropriate
+non-publishing job; do not append it to a publishing job and trigger that job just
+to run tests:
+
+```groovy
+stage('MySQL correctness') {
+ steps {
+ sh 'mvn -B -Pmysql-performance verify -Dperf.accounts=10000 -Dperf.transactions=50000 -Dperf.samples=5'
+ }
+ post {
+ always {
+ junit 'target/surefire-reports/*.xml,target/failsafe-reports/TEST-*.xml'
+ archiveArtifacts artifacts: 'target/mysql-performance/**,target/*-reports/**', allowEmptyArchive: true
+ }
+ }
+}
+```
+
+## Apply / rollback and tradeoffs
+
+These are explicit, versioned **manual SQL migrations**, not a newly introduced
+migration framework. Application startup does not discover this directory.
+Deploying the application artifact alone does **not** apply the improvement.
+The benchmark applies the actual files automatically on its owned disposable DB.
+For a separately started disposable MySQL 8.0 database, first inspect:
+
+```sql
+SELECT VERSION();
+SHOW CREATE TABLE account;
+SHOW INDEX FROM account;
+SHOW INDEX FROM `transaction`;
+```
+
+Do not apply if an equivalent visible full-column index with `username` as its
+leading column already exists, or if this index name is occupied. Do not make a
+unique index or change collation to force a plan. Apply once; these files are not
+idempotent and there is no migration-history table. Use the target schema and an
+interactive password prompt (never a command-line password):
+
+```bash
+mysql --host=127.0.0.1 --port="$MYSQL_PORT" --user="$MYSQL_USER" --password "$MYSQL_DATABASE" \
+ < sql/performance/V001__account_username_index.sql
+# Verify SHOW INDEX and EXPLAIN ANALYZE, then rollback if needed:
+mysql --host=127.0.0.1 --port="$MYSQL_PORT" --user="$MYSQL_USER" --password "$MYSQL_DATABASE" \
+ < sql/performance/U001__account_username_index.sql
+```
+
+Rollback removes only `idx_account_username`; no rows or constraints change and
+the original scan returns. No Java rollback or restart is needed. Both SQL files
+use `ALGORITHM=INPLACE, LOCK=NONE`; MySQL still needs metadata locks and the build
+uses I/O, temporary space, CPU and redo. They were exercised only on disposable
+infrastructure, not under concurrent production writes or long transactions.
+
+For the large fixture, `account.INDEX_LENGTH` rose from 0 to **3,686,400 bytes
+(3.52 MiB)** after `ANALYZE TABLE` with metadata cache expiry disabled. Allocation
+is page-granular and dataset-specific, not a universal per-row forecast. The index
+adds storage/buffer-pool pressure and maintenance on account inserts, deletes and
+username updates. Balance-only updates do not change the indexed username value.
+It adds no new transaction-table index/write path. Write-throughput cost was not
+benchmarked, and full 255-character usernames would consume more space.
+
+## Limits
+
+These measurements concern **this application and this synthetic MySQL dataset**,
+**not Oracle 19c or a 10-TB deployment**. No production data/access, load/concurrency
+test, cold-cache test, distributed network timing, multiple server versions,
+online migration under contention, or write-throughput benchmark was performed.
+Browser correctness is not a browser-latency benchmark. Existing transactionality,
+security and unbounded-history design were not altered. No deployment/publishing
+pipeline was changed or triggered, and no changes were made to
+[PR #361](https://github.com/COG-GTM/Springboot-BankApp/pull/361).
diff --git a/pom.xml b/pom.xml
index eed97c9e..20cfbffd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -28,8 +28,14 @@
17
+ 1.21.4
+
+ org.testcontainers
+ mysql
+ test
+
org.springframework.boot
spring-boot-starter-data-jpa
@@ -74,6 +80,30 @@
+
+
+ mysql-performance
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ 1.44
+
+
+
+
+ integration-testverify
+
+
+
+
+
+
+
+
diff --git a/scripts/performance/benchmark.sh b/scripts/performance/benchmark.sh
new file mode 100755
index 00000000..9323e394
--- /dev/null
+++ b/scripts/performance/benchmark.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+exec mvn -B -Pmysql-performance verify \
+ -Dperf.accounts="${PERF_ACCOUNTS:-100000}" \
+ -Dperf.transactions="${PERF_TRANSACTIONS:-1000000}" \
+ -Dperf.samples="${PERF_SAMPLES:-21}" \
+ -Dperf.warmup="${PERF_WARMUP:-5}" "$@"
diff --git a/sql/performance/U001__account_username_index.sql b/sql/performance/U001__account_username_index.sql
new file mode 100644
index 00000000..5daba8b5
--- /dev/null
+++ b/sql/performance/U001__account_username_index.sql
@@ -0,0 +1 @@
+ALTER TABLE account DROP INDEX idx_account_username, ALGORITHM=INPLACE, LOCK=NONE;
diff --git a/sql/performance/V001__account_username_index.sql b/sql/performance/V001__account_username_index.sql
new file mode 100644
index 00000000..15147b6a
--- /dev/null
+++ b/sql/performance/V001__account_username_index.sql
@@ -0,0 +1 @@
+ALTER TABLE account ADD INDEX idx_account_username (username), ALGORITHM=INPLACE, LOCK=NONE;
diff --git a/src/test/java/com/example/bankapp/performance/MySqlPerformanceIT.java b/src/test/java/com/example/bankapp/performance/MySqlPerformanceIT.java
new file mode 100644
index 00000000..6af1eef7
--- /dev/null
+++ b/src/test/java/com/example/bankapp/performance/MySqlPerformanceIT.java
@@ -0,0 +1,420 @@
+package com.example.bankapp.performance;
+
+import com.example.bankapp.model.Transaction;
+import com.example.bankapp.repository.AccountRepository;
+import com.example.bankapp.repository.TransactionRepository;
+import com.github.dockerjava.api.model.ExposedPort;
+import com.github.dockerjava.api.model.PortBinding;
+import com.github.dockerjava.api.model.Ports;
+import org.hibernate.resource.jdbc.spi.StatementInspector;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.dao.IncorrectResultSizeDataAccessException;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.testcontainers.containers.MySQLContainer;
+
+import javax.sql.DataSource;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.sql.*;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@SpringBootTest(properties = {
+ "spring.jpa.hibernate.ddl-auto=update",
+ "spring.jpa.show-sql=false",
+ "spring.jpa.properties.hibernate.session_factory.statement_inspector=com.example.bankapp.performance.MySqlPerformanceIT$SqlCapture",
+ "logging.level.org.testcontainers=ERROR",
+ "logging.level.com.github.dockerjava=ERROR"
+})
+@AutoConfigureMockMvc
+class MySqlPerformanceIT {
+ private static final String IMAGE = "mysql:8.0.40";
+ private static final MySQLContainer> MYSQL = new MySQLContainer<>(IMAGE)
+ .withDatabaseName("query_fixture")
+ .withUsername("fixture")
+ .withPassword(UUID.randomUUID().toString())
+ .withCommand("--innodb-buffer-pool-size=268435456")
+ .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig()
+ .withMemory(2L * 1024 * 1024 * 1024).withNanoCPUs(2_000_000_000L)
+ .withPortBindings(new PortBinding(Ports.Binding.bindIpAndPort("127.0.0.1", 0), new ExposedPort(3306))));
+ private static final Path OUT = Paths.get(System.getProperty("perf.output", "target/mysql-performance"));
+ private static final int ACCOUNTS = Integer.getInteger("perf.accounts", 10_000);
+ private static final int TRANSACTIONS = Integer.getInteger("perf.transactions", 50_000);
+ private static final int SAMPLES = Integer.getInteger("perf.samples", 11);
+ private static final int WARMUP = Integer.getInteger("perf.warmup", 3);
+ private static final Pattern ACTUAL = Pattern.compile("actual time=[0-9.]+\\.\\.([0-9.]+) rows=([0-9]+)");
+ private static final List TIMINGS = new ArrayList<>();
+ private static final List SUMMARY = new ArrayList<>();
+
+ @Autowired DataSource dataSource;
+ @Autowired AccountRepository accounts;
+ @Autowired TransactionRepository transactions;
+ @Autowired MockMvc mvc;
+
+ @DynamicPropertySource
+ static void database(DynamicPropertyRegistry registry) {
+ MYSQL.start();
+ registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
+ registry.add("spring.datasource.username", MYSQL::getUsername);
+ registry.add("spring.datasource.password", MYSQL::getPassword);
+ registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
+ registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect");
+ }
+
+ public static class SqlCapture implements StatementInspector {
+ static final List SQL = new CopyOnWriteArrayList<>();
+ @Override public String inspect(String sql) {
+ if (sql.startsWith("select") && !SQL.contains(sql)) SQL.add(sql);
+ return sql;
+ }
+ }
+
+ @Test
+ void baselineAndIndexedResultsAreEquivalent() throws Exception {
+ assertThat(ACCOUNTS).isBetween(100, 1_000_000);
+ assertThat(TRANSACTIONS).isBetween(1_000, 5_000_000);
+ assertThat(SAMPLES).isBetween(3, 101);
+ assertThat(WARMUP).isBetween(1, 20);
+ Files.createDirectories(OUT);
+ Files.deleteIfExists(OUT.resolve("equivalence.txt"));
+ TIMINGS.add("phase\tquery\tsample\tdb_explain_analyze_ms\tjdbc_materialize_ms\trows");
+ SUMMARY.add("phase\tquery\tsamples\twarmup\tmedian_db_explain_analyze_ms\tmedian_jdbc_materialize_ms\trows");
+ try (Connection c = dataSource.getConnection()) {
+ seed(c);
+ // The baseline is the schema at DevOps, before the versioned migration.
+ if (indexExists(c)) execute(c, "DROP INDEX idx_account_username ON account");
+ analyze(c);
+ metadata(c);
+ captureGeneratedSql();
+ Map queries = queries();
+ Map before = snapshots(c, queries);
+ verifyRepositorySemantics();
+ String beforePage = verifyWebSemantics(c);
+ schema(c, "before");
+ benchmark(c, "before", queries);
+ benchmarkApplication("before");
+ try {
+ migration(c, "V001__account_username_index.sql");
+ analyze(c);
+ schema(c, "after");
+ benchmark(c, "after", queries);
+ benchmarkApplication("after");
+ assertThat(snapshots(c, queries)).isEqualTo(before);
+ verifyRepositorySemantics();
+ assertThat(verifyWebSemantics(c)).isEqualTo(beforePage);
+ // Rollback itself is executable and must preserve the same results.
+ migration(c, "U001__account_username_index.sql");
+ assertThat(indexExists(c)).isFalse();
+ assertThat(snapshots(c, queries)).isEqualTo(before);
+ analyze(c);
+ schema(c, "rollback");
+ benchmark(c, "rollback", queries);
+ migration(c, "V001__account_username_index.sql");
+ assertThat(indexExists(c)).isTrue();
+ write("equivalence.txt", "PASS: full JDBC row digests/counts before = after = rollback; "
+ + "repository edge cases and authenticated HTTP behavior checked in both states.\n" + before);
+ } finally {
+ if (!indexExists(c)) migration(c, "V001__account_username_index.sql");
+ }
+ } finally {
+ write("samples.tsv", String.join("\n", TIMINGS) + "\n");
+ write("summary.tsv", String.join("\n", SUMMARY) + "\n");
+ }
+ }
+
+ private void seed(Connection c) throws SQLException {
+ execute(c, "CREATE TABLE digit(n INT PRIMARY KEY)");
+ execute(c, "INSERT INTO digit VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)");
+ execute(c, "CREATE TEMPORARY TABLE seq(n INT PRIMARY KEY)");
+ int size = Math.max(ACCOUNTS, TRANSACTIONS);
+ execute(c, "INSERT INTO seq SELECT 1+a.n+10*b.n+100*c.n+1000*d.n+10000*e.n+100000*f.n+1000000*g.n n "
+ + "FROM digit a CROSS JOIN digit b CROSS JOIN digit c CROSS JOIN digit d CROSS JOIN digit e "
+ + "CROSS JOIN digit f CROSS JOIN digit g HAVING n <= " + size);
+ execute(c, "INSERT INTO account(id,username,password,balance) SELECT n,CONCAT('user-',LPAD(n,7,'0')),"
+ + "REPEAT('!',60),1000.00+MOD(n,900000)/100 FROM seq WHERE n <= " + ACCOUNTS);
+ int hot = ACCOUNTS / 10 - 2;
+ int cold = ACCOUNTS * 7 / 10;
+ execute(c, "INSERT INTO `transaction`(id,account_id,amount,type,timestamp) SELECT n,"
+ + "CASE WHEN MOD(n,20)=0 THEN 1 WHEN MOD(n,10)<8 THEN 2+MOD(FLOOR(n/10)*7919+MOD(n,10)," + hot + ") "
+ + "ELSE " + (ACCOUNTS / 10) + "+MOD(FLOOR(n/10)*104729+MOD(n,10)," + cold + ") END,"
+ + "CAST(MOD(n,43)*1.25 AS DECIMAL(38,2)),"
+ + "CASE MOD(n,4) WHEN 0 THEN 'Deposit' WHEN 1 THEN 'Withdrawal' "
+ + "WHEN 2 THEN 'Transfer Out to synthetic' ELSE 'Transfer In from synthetic' END,"
+ + "TIMESTAMPADD(SECOND,FLOOR(n/3), '2024-01-01 00:00:00') FROM seq WHERE n <= " + TRANSACTIONS);
+ // Last accounts are outside the generated transaction range.
+ execute(c, "UPDATE account SET username='duplicate' WHERE id IN (" + (ACCOUNTS - 1) + "," + (ACCOUNTS - 2) + ")");
+ execute(c, "UPDATE account SET username=NULL, balance=NULL WHERE id=" + (ACCOUNTS - 3));
+ execute(c, "UPDATE account SET username='' WHERE id=" + (ACCOUNTS - 4));
+ execute(c, "UPDATE account SET username='Caf\u00e9' WHERE id=" + (ACCOUNTS - 5));
+ execute(c, "UPDATE account SET username=REPEAT('x',255), balance=999999999999999999999999999999999999.99 WHERE id=" + (ACCOUNTS - 6));
+ execute(c, "INSERT INTO `transaction`(account_id,amount,type,timestamp) VALUES "
+ + "(" + ACCOUNTS + ",0.00,'Deposit','2024-02-29 23:59:59.999999'),"
+ + "(" + ACCOUNTS + ",0.00,'Deposit','2024-02-29 23:59:59.999999'),"
+ + "(" + ACCOUNTS + ",NULL,NULL,NULL),"
+ + "(" + ACCOUNTS + ",-0.01,'Withdrawal','1970-01-01 00:00:00'),"
+ + "(" + ACCOUNTS + ",999999999999999999999999999999999999.99,'Deposit','2038-01-19 03:14:07'),"
+ + "(NULL,1.00,NULL,NULL)");
+ execute(c, "DROP TEMPORARY TABLE seq");
+ execute(c, "DROP TABLE digit");
+ }
+
+ private void captureGeneratedSql() throws IOException {
+ SqlCapture.SQL.clear();
+ accounts.findByUsername(username(1));
+ transactions.findByAccountId((long) ACCOUNTS);
+ write("hibernate-sql.txt", String.join(";\n", SqlCapture.SQL) + ";\n");
+ }
+
+ private Map queries() {
+ String accountSql = SqlCapture.SQL.stream().filter(s -> s.contains(".username=?")).findFirst().orElseThrow(AssertionError::new);
+ String historySql = SqlCapture.SQL.stream().filter(s -> s.contains(".account_id=?")).findFirst().orElseThrow(AssertionError::new);
+ Map q = new LinkedHashMap<>();
+ q.put("account-existing", new Query(accountSql, username(ACCOUNTS / 2)));
+ q.put("account-missing", new Query(accountSql, "absent-user"));
+ q.put("account-duplicate", new Query(accountSql, "duplicate"));
+ q.put("account-collation", new Query(accountSql, "CAFE"));
+ q.put("account-empty", new Query(accountSql, ""));
+ q.put("account-max-length", new Query(accountSql, String.join("", Collections.nCopies(255, "x"))));
+ q.put("history-high-volume", new Query(historySql, 1L));
+ q.put("history-empty", new Query(historySql, (long) ACCOUNTS - 7));
+ q.put("history-boundaries", new Query(historySql, (long) ACCOUNTS));
+ return q;
+ }
+
+ private void benchmark(Connection c, String phase, Map queries) throws Exception {
+ for (Map.Entry entry : queries.entrySet()) {
+ Query q = entry.getValue();
+ String plan = queryText(c, "EXPLAIN ANALYZE " + q.sql, q.parameter);
+ write(phase + "-" + entry.getKey() + "-plan.txt", "SQL: " + q.sql + "\nparameter: " + q.parameter + "\n" + plan);
+ if (entry.getKey().equals("account-existing")) {
+ assertThat(plan).contains(phase.equals("after") ? "using idx_account_username" : "Table scan");
+ }
+ for (int i = 0; i < WARMUP; i++) {
+ queryText(c, "EXPLAIN ANALYZE " + q.sql, q.parameter);
+ digest(c, q);
+ }
+ List db = new ArrayList<>();
+ List client = new ArrayList<>();
+ long rows = 0;
+ for (int i = 0; i < SAMPLES; i++) {
+ String explain = queryText(c, "EXPLAIN ANALYZE " + q.sql, q.parameter);
+ Matcher match = ACTUAL.matcher(explain);
+ assertThat(match.find()).as(explain).isTrue();
+ double execution = Double.parseDouble(match.group(1));
+ rows = Long.parseLong(match.group(2));
+ long start = System.nanoTime();
+ materialize(c, q);
+ double elapsed = (System.nanoTime() - start) / 1_000_000.0;
+ db.add(execution);
+ client.add(elapsed);
+ TIMINGS.add(phase + "\t" + entry.getKey() + "\t" + i + "\t" + execution + "\t" + elapsed + "\t" + rows);
+ }
+ SUMMARY.add(phase + "\t" + entry.getKey() + "\t" + SAMPLES + "\t" + WARMUP + "\t" + median(db) + "\t" + median(client) + "\t" + rows);
+ }
+ }
+
+ private void verifyRepositorySemantics() {
+ assertThat(accounts.findByUsername("absent-user")).isEmpty();
+ assertThat(accounts.findByUsername(null).get().getBalance()).isNull();
+ assertThat(accounts.findByUsername("").get().getId()).isEqualTo((long) ACCOUNTS - 4);
+ assertThat(accounts.findByUsername("CAFE").get().getId()).isEqualTo((long) ACCOUNTS - 5);
+ assertThat(accounts.findByUsername(username(1)).get().getBalance()).isEqualByComparingTo("1000.01");
+ assertThatThrownBy(() -> accounts.findByUsername("duplicate")).isInstanceOf(IncorrectResultSizeDataAccessException.class);
+ assertThat(transactions.findByAccountId((long) ACCOUNTS - 7)).isEmpty();
+ List high = transactions.findByAccountId(1L);
+ assertThat(high).hasSize(TRANSACTIONS / 20);
+ assertThat(high).allSatisfy(t -> assertThat(t.getAccount().getId()).isEqualTo(1L));
+ List edge = transactions.findByAccountId((long) ACCOUNTS);
+ assertThat(edge).hasSize(5);
+ assertThat(edge.stream().filter(t -> BigDecimal.ZERO.equals(t.getAmount() == null ? null : t.getAmount().stripTrailingZeros())).count()).isEqualTo(2);
+ assertThat(edge).anySatisfy(t -> {
+ assertThat(t.getAmount()).isNull();
+ assertThat(t.getType()).isNull();
+ assertThat(t.getTimestamp()).isNull();
+ });
+ assertThat(edge).anySatisfy(t -> {
+ assertThat(t.getAmount()).isEqualByComparingTo("-0.01");
+ assertThat(t.getTimestamp()).isEqualTo(LocalDateTime.of(1970, 1, 1, 0, 0));
+ });
+ assertThat(edge).anySatisfy(t -> assertThat(t.getAmount()).isEqualByComparingTo("999999999999999999999999999999999999.99"));
+ assertThat(edge).anySatisfy(t -> assertThat(t.getTimestamp()).isEqualTo(LocalDateTime.of(2024, 2, 29, 23, 59, 59, 999999000)));
+ assertThat(transactions.findByAccountId(null)).hasSize(1);
+ }
+
+ private void benchmarkApplication(String phase) throws Exception {
+ List samples = new ArrayList<>();
+ samples.add("endpoint\tsample\tmockmvc_response_ms\tresponse_bytes");
+ List summary = new ArrayList<>();
+ summary.add("endpoint\tsamples\twarmup\tmedian_mockmvc_response_ms");
+ for (String endpoint : Arrays.asList("/dashboard", "/transactions")) {
+ for (int i = 0; i < WARMUP; i++) mvc.perform(get(endpoint).with(user(username(ACCOUNTS / 2)))).andExpect(status().isOk());
+ List elapsed = new ArrayList<>();
+ for (int i = 0; i < SAMPLES; i++) {
+ long start = System.nanoTime();
+ byte[] response = mvc.perform(get(endpoint).with(user(username(ACCOUNTS / 2))))
+ .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray();
+ double ms = (System.nanoTime() - start) / 1_000_000.0;
+ elapsed.add(ms);
+ samples.add(endpoint + "\t" + i + "\t" + ms + "\t" + response.length);
+ }
+ summary.add(endpoint + "\t" + SAMPLES + "\t" + WARMUP + "\t" + median(elapsed));
+ }
+ write(phase + "-application-samples.tsv", String.join("\n", samples) + "\n");
+ write(phase + "-application-summary.tsv", String.join("\n", summary) + "\n");
+ }
+
+ private String verifyWebSemantics(Connection c) throws Exception {
+ String sender = username(ACCOUNTS - 7);
+ String recipient = username(ACCOUNTS - 8);
+ execute(c, "UPDATE account SET balance=1000.00 WHERE id IN (" + (ACCOUNTS - 7) + "," + (ACCOUNTS - 8) + ")");
+ mvc.perform(get("/transactions")).andExpect(status().is3xxRedirection()).andExpect(redirectedUrlPattern("**/login"));
+ mvc.perform(post("/transfer").param("toUsername", recipient).param("amount", "10.01"))
+ .andExpect(status().is3xxRedirection()).andExpect(redirectedUrlPattern("**/login"));
+ mvc.perform(post("/transfer").with(user(sender)).param("toUsername", recipient).param("amount", "10.01"))
+ .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/dashboard"));
+ assertThat(accounts.findByUsername(sender).get().getBalance()).isEqualByComparingTo("989.99");
+ assertThat(accounts.findByUsername(recipient).get().getBalance()).isEqualByComparingTo("1010.01");
+ List debit = transactions.findByAccountId((long) ACCOUNTS - 7);
+ assertThat(debit).hasSize(1);
+ assertThat(debit.get(0).getAmount()).isEqualByComparingTo("10.01");
+ assertThat(debit.get(0).getType()).isEqualTo("Transfer Out to " + recipient);
+ List credit = transactions.findByAccountId((long) ACCOUNTS - 8);
+ assertThat(credit).hasSize(1);
+ assertThat(credit.get(0).getAmount()).isEqualByComparingTo("10.01");
+ assertThat(credit.get(0).getType()).isEqualTo("Transfer In from " + sender);
+ mvc.perform(post("/transfer").with(user(sender)).param("toUsername", recipient).param("amount", "10000.00"))
+ .andExpect(status().isOk()).andExpect(model().attribute("error", "Insufficient funds"));
+ mvc.perform(post("/transfer").with(user(sender)).param("toUsername", "absent-user").param("amount", "1.00"))
+ .andExpect(status().isOk()).andExpect(model().attribute("error", "Recipient account not found"));
+ mvc.perform(get("/transactions").with(user(sender)).param("accountId", "1"))
+ .andExpect(status().isOk()).andExpect(model().attribute("transactions", org.hamcrest.Matchers.hasSize(1)));
+ execute(c, "DELETE FROM `transaction` WHERE id > " + (TRANSACTIONS + 6));
+ execute(c, "ALTER TABLE `transaction` AUTO_INCREMENT=" + (TRANSACTIONS + 7));
+ execute(c, "UPDATE account SET balance=1000.00+MOD(id,900000)/100 WHERE id IN (" + (ACCOUNTS - 7) + "," + (ACCOUNTS - 8) + ")");
+ return mvc.perform(get("/transactions").with(user(username(ACCOUNTS / 2))))
+ .andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
+ }
+
+ private Map snapshots(Connection c, Map queries) throws Exception {
+ Map values = new LinkedHashMap<>();
+ for (Map.Entry q : queries.entrySet()) values.put(q.getKey(), digest(c, q.getValue()));
+ values.put("null-username", digest(c, new Query("SELECT id,balance,username FROM account WHERE username IS NULL", null)));
+ values.put("null-account", digest(c, new Query("SELECT * FROM `transaction` WHERE account_id IS NULL", null)));
+ return values;
+ }
+
+ private String digest(Connection c, Query q) throws Exception {
+ MessageDigest hash = MessageDigest.getInstance("SHA-256");
+ long count = 0;
+ try (PreparedStatement s = prepare(c, q.sql, q.parameter); ResultSet rs = s.executeQuery()) {
+ while (rs.next()) {
+ count++;
+ for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) {
+ String value = rs.getString(i);
+ byte[] bytes = (value == null ? "N;" : value.length() + ":" + value + ";").getBytes(StandardCharsets.UTF_8);
+ hash.update(bytes);
+ }
+ }
+ }
+ return count + ":" + Base64.getEncoder().encodeToString(hash.digest());
+ }
+
+ private void materialize(Connection c, Query q) throws SQLException {
+ try (PreparedStatement s = prepare(c, q.sql, q.parameter); ResultSet rs = s.executeQuery()) {
+ while (rs.next()) for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) rs.getObject(i);
+ }
+ }
+
+ private void metadata(Connection c) throws Exception {
+ write("environment.txt", queryText(c, "SELECT VERSION(), @@version_comment, @@innodb_buffer_pool_size, @@collation_server, @@transaction_isolation", null)
+ + "\nimage=" + IMAGE + "\ncontainerImageId=" + MYSQL.getContainerInfo().getImageId()
+ + "\nMySQL limits: 2 CPUs, 2 GiB RAM; buffer pool: 256 MiB\n"
+ + "JVM=" + System.getProperty("java.version") + "; os=" + System.getProperty("os.name") + " " + System.getProperty("os.arch")
+ + "; visible CPUs=" + Runtime.getRuntime().availableProcessors() + "; JVM max heap=" + Runtime.getRuntime().maxMemory()
+ + "\naccounts=" + ACCOUNTS + "; generated transactions=" + TRANSACTIONS + "; boundary transactions=6\n"
+ + "samples=" + SAMPLES + "; warmup=" + WARMUP + "\n"
+ + queryText(c, "SELECT COUNT(*) accounts FROM account", null)
+ + queryText(c, "SELECT COUNT(*) transactions, COUNT(DISTINCT account_id) active_accounts FROM `transaction`", null)
+ + queryText(c, "SELECT account_id,COUNT(*) row_count FROM `transaction` GROUP BY account_id ORDER BY row_count DESC LIMIT 10", null));
+ Path cpu = Paths.get("/proc/cpuinfo");
+ Path mem = Paths.get("/proc/meminfo");
+ if (Files.exists(cpu)) write("host-cpu.txt", new String(Files.readAllBytes(cpu), StandardCharsets.UTF_8));
+ if (Files.exists(mem)) write("host-memory.txt", new String(Files.readAllBytes(mem), StandardCharsets.UTF_8));
+ }
+
+ private void schema(Connection c, String phase) throws Exception {
+ execute(c, "SET SESSION information_schema_stats_expiry=0");
+ write(phase + "-schema.txt", queryText(c, "SHOW CREATE TABLE account", null)
+ + queryText(c, "SHOW CREATE TABLE `transaction`", null)
+ + queryText(c, "SHOW INDEX FROM account", null)
+ + queryText(c, "SHOW INDEX FROM `transaction`", null)
+ + queryText(c, "SELECT TABLE_NAME,TABLE_ROWS,DATA_LENGTH,INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()", null));
+ }
+
+ private boolean indexExists(Connection c) throws SQLException {
+ try (Statement s = c.createStatement(); ResultSet rs = s.executeQuery("SHOW INDEX FROM account WHERE Key_name='idx_account_username'")) { return rs.next(); }
+ }
+
+ private void migration(Connection c, String file) throws Exception {
+ String sql = new String(Files.readAllBytes(Paths.get("sql/performance", file)), StandardCharsets.UTF_8);
+ execute(c, sql);
+ }
+
+ private void analyze(Connection c) throws SQLException {
+ execute(c, "ANALYZE TABLE account, `transaction`");
+ }
+
+ private void execute(Connection c, String sql) throws SQLException {
+ try (Statement s = c.createStatement()) { s.execute(sql); }
+ }
+
+ private PreparedStatement prepare(Connection c, String sql, Object parameter) throws SQLException {
+ PreparedStatement s = c.prepareStatement(sql);
+ if (sql.contains("?")) s.setObject(1, parameter);
+ return s;
+ }
+
+ private String queryText(Connection c, String sql, Object parameter) throws SQLException {
+ StringBuilder out = new StringBuilder();
+ try (PreparedStatement s = prepare(c, sql, parameter); ResultSet rs = s.executeQuery()) {
+ for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) out.append(rs.getMetaData().getColumnLabel(i)).append('\t');
+ out.append('\n');
+ while (rs.next()) {
+ for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) out.append(rs.getString(i)).append('\t');
+ out.append('\n');
+ }
+ }
+ return out.toString();
+ }
+
+ private static String username(int n) { return String.format(Locale.ROOT, "user-%07d", n); }
+ private static double median(List values) {
+ Collections.sort(values);
+ int middle = values.size() / 2;
+ return values.size() % 2 == 0 ? (values.get(middle - 1) + values.get(middle)) / 2 : values.get(middle);
+ }
+ private static void write(String name, String body) throws IOException { Files.write(OUT.resolve(name), body.getBytes(StandardCharsets.UTF_8)); }
+ private static class Query {
+ final String sql;
+ final Object parameter;
+ Query(String sql, Object parameter) { this.sql = sql; this.parameter = parameter; }
+ }
+}