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). ![Login diagram](images/login.png) 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; } + } +}