diff --git a/README.md b/README.md index 84bf051..9f466bb 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ springOutboxPublisher.publish( ) ``` -**Using MySQL instead of PostgreSQL?** Replace `okapi-postgres` with `okapi-mysql` in your dependencies — no code changes needed. +**Using MySQL instead of PostgreSQL?** Replace `okapi-postgres` with `okapi-mysql` in your dependencies — no code changes needed. Add `rewriteBatchedStatements=true` to your JDBC URL — see [Performance](#performance) for why. > **Note:** Spring and Kafka versions are not forced by okapi — you control them. > Okapi uses plain JDBC internally — it works with any `PlatformTransactionManager` (JPA, JDBC, jOOQ, Exposed, etc.). @@ -306,7 +306,28 @@ Throughput on a single instance (MacBook M3 Max, JDK 21 LTS, May 2026): Kafka throughput jumped 16-45× over the original sync-sequential baseline thanks to the `deliverBatch` fire-flush-await pattern. HTTP parallel `sendAsync` is next; multi-threaded scheduler scaling is in the roadmap. -Full methodology, raw JMH results, before/after per change: [`benchmarks/`](benchmarks/). +### MySQL: `rewriteBatchedStatements` + +`OutboxStore.updateAfterProcessingBatch()` writes back a whole processed batch via one JDBC +`executeBatch()` call. On Postgres, PgJDBC pipelines batched statements over the wire natively, so +this already collapses N roundtrips into ~1. **MySQL Connector/J does not** — by default it sends +one roundtrip per statement in the batch regardless of `addBatch()`/`executeBatch()`, silently +negating the optimization. Add `rewriteBatchedStatements=true` to the JDBC URL +(`jdbc:mysql://host:3306/db?rewriteBatchedStatements=true`) so Connector/J rewrites the batch into +a single multi-statement round trip. This is a driver/connection setting — okapi cannot set it for +you since it doesn't own your `DataSource`. + +We verified the rewrite genuinely happens (Connector/J's own query profiler confirms a batch of +1000 `UPDATE`s becomes one multi-statement round trip), but couldn't measure a net speedup from it +in our benchmark — see +[`results-mysql-rewrite-batched-statements.md`](benchmarks/results-mysql-rewrite-batched-statements.md) +for why (a client-side response-decoding cost that scales with batch size). We still recommend +enabling it — the round-trip savings are real and matter most against a network-hosted MySQL — but +unlike Postgres's measured 10.2×, we don't have a MySQL multiplier to back it with. + +Full methodology, raw JMH results, before/after per change: [`benchmarks/`](benchmarks/), including +[`results-postopt-KOJAK-75.md`](benchmarks/results-postopt-KOJAK-75.md) for the batch-UPDATE +numbers above. ## Build diff --git a/benchmarks/README.md b/benchmarks/README.md index 1ddcadc..5965ccf 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -103,3 +103,8 @@ investigate variability sources (background processes, thermal throttling, GC). - [`results-baseline-2026-04.md`](results-baseline-2026-04.md) — pre-optimization baseline (sync sequential delivery, single-threaded scheduler) +- [`results-postopt-KOJAK-75.md`](results-postopt-KOJAK-75.md) — batch UPDATE via JDBC + `executeBatch()`, Postgres: 10.2× +- [`results-mysql-rewrite-batched-statements.md`](results-mysql-rewrite-batched-statements.md) — + same optimization on MySQL: mechanism verified correct, but no net speedup measured at this + batch size/hardware — read before assuming the Postgres multiplier transfers diff --git a/benchmarks/mysql-outbox-store-update-batch.json b/benchmarks/mysql-outbox-store-update-batch.json new file mode 100644 index 0000000..08c3e07 --- /dev/null +++ b/benchmarks/mysql-outbox-store-update-batch.json @@ -0,0 +1,248 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.softwaremill.okapi.benchmarks.MysqlOutboxStoreUpdateBatchBenchmark.batchUpdate", + "mode" : "avgt", + "threads" : 1, + "forks" : 2, + "jvm" : "/Users/rawo/.sdkman/candidates/java/25.0.2-open/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-69", + "warmupIterations" : 3, + "warmupTime" : "10 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "30 s", + "measurementBatchSize" : 1, + "params" : { + "rewriteBatchedStatements" : "false" + }, + "primaryMetric" : { + "score" : 0.3559014144161017, + "scoreError" : 0.039665334059952734, + "scoreConfidence" : [ + 0.3162360803561489, + 0.39556674847605444 + ], + "scorePercentiles" : { + "0.0" : 0.3271754299512195, + "50.0" : 0.3490440348943812, + "90.0" : 0.4041841761904356, + "95.0" : 0.40463652209375, + "99.0" : 0.40463652209375, + "99.9" : 0.40463652209375, + "99.99" : 0.40463652209375, + "99.999" : 0.40463652209375, + "99.9999" : 0.40463652209375, + "100.0" : 0.40463652209375 + }, + "scoreUnit" : "ms/op", + "rawData" : [ + [ + 0.347101783, + 0.34787377697297295, + 0.35790461478378377, + 0.330397057325, + 0.3271754299512195 + ], + [ + 0.3502142928157895, + 0.35157600205263156, + 0.34202160210526317, + 0.40463652209375, + 0.40011306306060607 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.softwaremill.okapi.benchmarks.MysqlOutboxStoreUpdateBatchBenchmark.batchUpdate", + "mode" : "avgt", + "threads" : 1, + "forks" : 2, + "jvm" : "/Users/rawo/.sdkman/candidates/java/25.0.2-open/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-69", + "warmupIterations" : 3, + "warmupTime" : "10 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "30 s", + "measurementBatchSize" : 1, + "params" : { + "rewriteBatchedStatements" : "true" + }, + "primaryMetric" : { + "score" : 0.37166066075555915, + "scoreError" : 0.025002847932276924, + "scoreConfidence" : [ + 0.34665781282328223, + 0.39666350868783606 + ], + "scorePercentiles" : { + "0.0" : 0.3464775708918919, + "50.0" : 0.37626819665756306, + "90.0" : 0.3942688445459893, + "95.0" : 0.39463005684848484, + "99.0" : 0.39463005684848484, + "99.9" : 0.39463005684848484, + "99.99" : 0.39463005684848484, + "99.999" : 0.39463005684848484, + "99.9999" : 0.39463005684848484, + "100.0" : 0.39463005684848484 + }, + "scoreUnit" : "ms/op", + "rawData" : [ + [ + 0.3798628504117647, + 0.3550040564054054, + 0.3530975202972973, + 0.3464775708918919, + 0.38176093861764704 + ], + [ + 0.36221928694444444, + 0.3772910452857143, + 0.39463005684848484, + 0.3910179338235294, + 0.3752453480294118 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.softwaremill.okapi.benchmarks.MysqlOutboxStoreUpdateBatchBenchmark.individualUpdates", + "mode" : "avgt", + "threads" : 1, + "forks" : 2, + "jvm" : "/Users/rawo/.sdkman/candidates/java/25.0.2-open/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-69", + "warmupIterations" : 3, + "warmupTime" : "10 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "30 s", + "measurementBatchSize" : 1, + "params" : { + "rewriteBatchedStatements" : "false" + }, + "primaryMetric" : { + "score" : 0.38256680561885564, + "scoreError" : 0.030280864536129378, + "scoreConfidence" : [ + 0.35228594108272626, + 0.412847670154985 + ], + "scorePercentiles" : { + "0.0" : 0.34546297335897436, + "50.0" : 0.3830508738666667, + "90.0" : 0.4125975176998106, + "95.0" : 0.4131799544375, + "99.0" : 0.4131799544375, + "99.9" : 0.4131799544375, + "99.99" : 0.4131799544375, + "99.999" : 0.4131799544375, + "99.9999" : 0.4131799544375, + "100.0" : 0.4131799544375 + }, + "scoreUnit" : "ms/op", + "rawData" : [ + [ + 0.4131799544375, + 0.40735558706060604, + 0.3808168333333333, + 0.3900203615882353, + 0.37560300837142857 + ], + [ + 0.3769106880285714, + 0.3852849144, + 0.39028472679411763, + 0.3607490088157895, + 0.34546297335897436 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.softwaremill.okapi.benchmarks.MysqlOutboxStoreUpdateBatchBenchmark.individualUpdates", + "mode" : "avgt", + "threads" : 1, + "forks" : 2, + "jvm" : "/Users/rawo/.sdkman/candidates/java/25.0.2-open/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.2", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.2+10-69", + "warmupIterations" : 3, + "warmupTime" : "10 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "30 s", + "measurementBatchSize" : 1, + "params" : { + "rewriteBatchedStatements" : "true" + }, + "primaryMetric" : { + "score" : 0.40070849064122066, + "scoreError" : 0.048099097398679624, + "scoreConfidence" : [ + 0.35260939324254104, + 0.4488075880399003 + ], + "scorePercentiles" : { + "0.0" : 0.35037048239473684, + "50.0" : 0.3904555334571429, + "90.0" : 0.45089621283548387, + "95.0" : 0.4520804286774194, + "99.0" : 0.4520804286774194, + "99.9" : 0.4520804286774194, + "99.99" : 0.4520804286774194, + "99.999" : 0.4520804286774194, + "99.9999" : 0.4520804286774194, + "100.0" : 0.4520804286774194 + }, + "scoreUnit" : "ms/op", + "rawData" : [ + [ + 0.35037048239473684, + 0.4520804286774194, + 0.38026717734285714, + 0.3931627822, + 0.4116281914375 + ], + [ + 0.37589613814285716, + 0.38774828471428574, + 0.42899321615625, + 0.4402382702580645, + 0.3866999350882353 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + diff --git a/benchmarks/outbox-store-update-batch.json b/benchmarks/outbox-store-update-batch.json new file mode 100644 index 0000000..25f90f2 --- /dev/null +++ b/benchmarks/outbox-store-update-batch.json @@ -0,0 +1,124 @@ +[ + { + "jmhVersion": "1.37", + "benchmark": "com.softwaremill.okapi.benchmarks.OutboxStoreUpdateBatchBenchmark.batchUpdate", + "mode": "avgt", + "threads": 1, + "forks": 2, + "jvm": "21.0.3-tem", + "jvmArgs": [ + "-Xms8g", + "-Xmx8g", + "-XX:+UseG1GC", + "-Dliquibase.duplicateFileMode=WARN" + ], + "jdkVersion": "21.0.3", + "vmName": "OpenJDK 64-Bit Server VM", + "vmVersion": "21.0.3+9-LTS", + "warmupIterations": 3, + "warmupTime": "10 s", + "warmupBatchSize": 1, + "measurementIterations": 5, + "measurementTime": "30 s", + "measurementBatchSize": 1, + "primaryMetric": { + "score": 0.01833648456881728, + "scoreError": 0.0020169953236029692, + "scoreConfidence": [ + 0.016319489245214314, + 0.02035347989242025 + ], + "scorePercentiles": { + "0.0": 0.01757570200689655, + "50.0": 0.01795770926362434, + "90.0": 0.021707259911781882, + "95.0": 0.022077103971223023, + "99.0": 0.022077103971223023, + "99.9": 0.022077103971223023, + "99.99": 0.022077103971223023, + "99.999": 0.022077103971223023, + "99.9999": 0.022077103971223023, + "100.0": 0.022077103971223023 + }, + "scoreUnit": "ms/op", + "rawData": [ + [ + 0.022077103971223023, + 0.01757570200689655, + 0.018056688713235295, + 0.017920058964285713, + 0.017901921328671327 + ], + [ + 0.018378663376811596, + 0.017995359562962963, + 0.017748137673913044, + 0.017655013909722222, + 0.018056196180451126 + ] + ] + }, + "secondaryMetrics": {} + }, + { + "jmhVersion": "1.37", + "benchmark": "com.softwaremill.okapi.benchmarks.OutboxStoreUpdateBatchBenchmark.individualUpdates", + "mode": "avgt", + "threads": 1, + "forks": 2, + "jvm": "21.0.3-tem", + "jvmArgs": [ + "-Xms8g", + "-Xmx8g", + "-XX:+UseG1GC", + "-Dliquibase.duplicateFileMode=WARN" + ], + "jdkVersion": "21.0.3", + "vmName": "OpenJDK 64-Bit Server VM", + "vmVersion": "21.0.3+9-LTS", + "warmupIterations": 3, + "warmupTime": "10 s", + "warmupBatchSize": 1, + "measurementIterations": 5, + "measurementTime": "30 s", + "measurementBatchSize": 1, + "primaryMetric": { + "score": 0.18760605268538347, + "scoreError": 0.007994898663889173, + "scoreConfidence": [ + 0.1796111540214943, + 0.19560095134927263 + ], + "scorePercentiles": { + "0.0": 0.18065189692682926, + "50.0": 0.18718155963969363, + "90.0": 0.19567959736753246, + "95.0": 0.19579146863636362, + "99.0": 0.19579146863636362, + "99.9": 0.19579146863636362, + "99.99": 0.19579146863636362, + "99.999": 0.19579146863636362, + "99.9999": 0.19579146863636362, + "100.0": 0.19579146863636362 + }, + "scoreUnit": "ms/op", + "rawData": [ + [ + 0.185374561, + 0.18952913407792207, + 0.18065189692682926, + 0.19467275594805195, + 0.1915334856025641 + ], + [ + 0.19579146863636362, + 0.18149903904938272, + 0.1871130567922078, + 0.18725006248717949, + 0.18264506633333333 + ] + ] + }, + "secondaryMetrics": {} + } +] diff --git a/benchmarks/results-mysql-rewrite-batched-statements.md b/benchmarks/results-mysql-rewrite-batched-statements.md new file mode 100644 index 0000000..6d3d841 --- /dev/null +++ b/benchmarks/results-mysql-rewrite-batched-statements.md @@ -0,0 +1,72 @@ +# MySQL `rewriteBatchedStatements` — Results (KOJAK-75 follow-up) + +Same machine/config as [`results-postopt-KOJAK-75.md`](results-postopt-KOJAK-75.md): JDK 21 LTS, +full JMH config `fork=2, warmup=3 × 10s, iter=5 × 30s` (n=10 samples per benchmark), MySQL 8.0 via +Testcontainers. + +## Headline numbers — UPDATE phase only, 1000 entries + +| Benchmark | `rewriteBatchedStatements` | ms/op (per entry) | **Delta vs. false** | +|--------------------|:---:|---|---| +| `individualUpdates` | false | 0.383 ± 0.030 | — | +| `individualUpdates` | true | 0.401 ± 0.048 | not significant | +| `batchUpdate` | false | 0.356 ± 0.040 | — | +| `batchUpdate` | true | 0.372 ± 0.025 | not significant | + +Raw JSON: [`mysql-outbox-store-update-batch.json`](mysql-outbox-store-update-batch.json). + +**No configuration in this table shows a statistically significant difference from any other — +every score falls inside the others' error bars.** This does **not** mean `rewriteBatchedStatements` +is useless; it means this benchmark, on this hardware, can't demonstrate its benefit. Read on. + +## What we verified independently of the numbers above + +Because a null result here would otherwise look like "batching doesn't help on MySQL" — which +contradicts both the Connector/J docs and the Postgres result — we checked what actually crosses +the wire using Connector/J's own query profiler (`profileSQL=true&logger=StandardLogger`) on the +real `MysqlOutboxStore.updateAfterProcessingBatch` code path, batching 1000 `UPDATE`s: + +- With `rewriteBatchedStatements=true`, the driver **does** rewrite the batch into a single + multi-statement round trip. Server-side execution of that one combined statement: **2 ms**. +- Immediately after, decoding the combined response into 1000 individual JDBC update-counts + (the `[FETCH]` phase in Connector/J's profiler log) took **126 ms**. + +So the round-trip savings are real (2 ms of server work instead of ~1000 individual round trips), +but at this batch size they're offset by client-side cost of unpacking the combined response. The +two effects landing in the same ballpark is why `batchUpdate(true)` and `batchUpdate(false)` are +indistinguishable in the table above — not because the rewrite silently fails. + +## Why this differs from the Postgres result + +[`results-postopt-KOJAK-75.md`](results-postopt-KOJAK-75.md) measured a clean 10.2× for the +equivalent Postgres benchmark. That comparison isn't apples-to-apples with this one: + +- PgJDBC pipelines batched statements over the extended query protocol with no configuration + needed and no equivalent large-response decode step. +- This machine's round-trip latency to a loopback Testcontainers instance is already sub-millisecond + for both databases — the scenario `rewriteBatchedStatements` targets (real network latency between + app and DB) isn't present here for either engine, but MySQL's added decode cost only shows up + because we went looking for it specifically. + +## Practical takeaway + +- The property does what Connector/J's docs say: it eliminates N−1 round trips. That's a genuine + win when app↔DB round-trip time is non-trivial (production, cross-AZ, real network hops) — + which this benchmark can't reproduce on localhost. +- At very large batch sizes, factor in the client-side decode cost this investigation surfaced; + it isn't documented by MySQL and may partially offset the round-trip savings depending on your + actual batch size and network latency. We don't have a documented threshold for where the + trade-off flips. +- We recommend enabling `rewriteBatchedStatements=true` regardless (per the README) since it's + never harmful for this UPDATE-by-primary-key pattern, but we can't publish a MySQL speedup + multiplier the way we did for Postgres — measure it against your own production batch size and + network latency if throughput at this stage of the pipeline matters to you. + +## Verification context + +- Diagnostic method: Connector/J `profileSQL=true&logger=com.mysql.cj.log.StandardLogger` against + the real `MysqlOutboxStore.updateAfterProcessingBatch` path (not a synthetic query), 1000 entries. +- Benchmark: `MysqlOutboxStoreUpdateBatchBenchmark`, `@Param("false", "true")` on + `rewriteBatchedStatements`, one physical connection reused for the whole JMH trial (not reopened + per invocation) to match how okapi actually uses a connection per scheduler tick rather than per + `processNext()` call. diff --git a/benchmarks/results-postopt-KOJAK-75.md b/benchmarks/results-postopt-KOJAK-75.md new file mode 100644 index 0000000..fb51dbe --- /dev/null +++ b/benchmarks/results-postopt-KOJAK-75.md @@ -0,0 +1,68 @@ +# Batch UPDATE via JDBC executeBatch — Results (KOJAK-75) + +Measured on the same machine/config as [`results-kafka-deliverbatch.md`](results-kafka-deliverbatch.md): +JDK 21 LTS, Postgres 16 via Testcontainers, full JMH config `fork=2, warmup=3 × 10s, iter=5 × 30s` +(n=10 samples per benchmark). + +## Headline numbers — UPDATE phase only, 1000 entries + +`OutboxStoreUpdateBatchBenchmark` isolates the persistence step: 1000 entries are claimed and +pre-computed as already-processed (`toDelivered`) in `@Setup(Level.Invocation)`, then each +`@Benchmark` method times only the write-back — either N individual +`updateAfterProcessing()` calls or a single `updateAfterProcessingBatch()` `executeBatch()` call. + +| Method | ms/op (per entry) | Total for 1000 entries | **Improvement** | +|---------------------|------------------------|-------------------------|------------------| +| `individualUpdates` | 0.1876 ± 0.0080 | ~187.6 ms | baseline | +| `batchUpdate` | 0.0183 ± 0.0020 | ~18.3 ms | **10.2×** | + +Raw JSON: [`outbox-store-update-batch.json`](outbox-store-update-batch.json). + +## What changed + +- `OutboxStore.updateAfterProcessingBatch(entries)` — new method, default implementation loops + `updateAfterProcessing()` (one JDBC roundtrip per entry, unchanged behavior for stores that + don't override it). +- `PostgresOutboxStore` / `MysqlOutboxStore` override it with a single `PreparedStatement` + reused across all entries via `addBatch()` / `executeBatch()`, touching only the 5 mutable + columns (`status`, `updated_at`, `retries`, `last_attempt`, `last_error`) instead of the full + 11-column upsert used by `persist()`. +- `OutboxProcessor.processNext()` now calls `store.updateAfterProcessingBatch(processed)` once + per drained batch instead of looping `updateAfterProcessing()` per entry. + +## Reading the numbers + +- 10.2× on localhost Postgres (Testcontainers, ~sub-ms RTT) is already above the ticket's + 50-70% *relative* improvement estimate — expected, since that estimate was for the **whole** + Kafka-delivery batch (deliver + update), whereas this benchmark isolates the update phase + alone, which is exactly where all the savings land. +- Real-world gains should be **larger**, not smaller: this machine's Postgres RTT is well under + 1ms, so the "N roundtrips" baseline is already best-case. Against a real network-hosted + Postgres (1-5 ms RTT), `individualUpdates` would scale roughly linearly with N × RTT while + `batchUpdate` stays close to one roundtrip — the gap should widen further. +- Error bars (`±` above) are small relative to the score for both benchmarks, so the 10.2× + figure is not noise. + +## Caveat on this run's full-pipeline numbers + +This run also re-executed `KafkaThroughputBenchmark` / `HttpThroughputBenchmark` / +`DelivererMicroBenchmark` as part of the same `:okapi-benchmarks:jmh` invocation (a benchmark +filter that was expected to narrow the run to `OutboxStoreUpdateBatchBenchmark` alone was not +honored by the `me.champeau.jmh` plugin). The full run's wall clock was affected by an +environment interruption partway through (host suspend/resume), so the `KafkaThroughputBenchmark` +/ `HttpThroughputBenchmark` numbers from `results.json` in this run are **not** directly +comparable to the [KOJAK-73 baseline](results-kafka-deliverbatch.md) and should be re-measured +in a clean run before drawing conclusions about end-to-end throughput improvement. Only the +`OutboxStoreUpdateBatchBenchmark` numbers above — captured within that same run but validated by +tight, consistent error bars across both forks — are reported here. + +## Verification context + +- Unit tests: `OutboxStoreTest` (default `updateAfterProcessingBatch` loop behavior), + `OutboxProcessorTest` (asserts `processNext()` calls the batch method exactly once instead of + per-entry). +- Integration tests: `OutboxStoreContractTests` — mixed final statuses (DELIVERED + PENDING + retry + FAILED) persisted via one `updateAfterProcessingBatch()` call, run against both + Postgres and MySQL. +- ktlint clean, full `./gradlew build` green (core, postgres, mysql, micrometer, + integration-tests, spring-boot). diff --git a/okapi-benchmarks/build.gradle.kts b/okapi-benchmarks/build.gradle.kts index 51eac44..d99e917 100644 --- a/okapi-benchmarks/build.gradle.kts +++ b/okapi-benchmarks/build.gradle.kts @@ -7,15 +7,18 @@ dependencies { // Okapi modules under measurement jmh(project(":okapi-core")) jmh(project(":okapi-postgres")) + jmh(project(":okapi-mysql")) jmh(project(":okapi-kafka")) jmh(project(":okapi-http")) - // Testcontainers — real Postgres + real Kafka for end-to-end throughput + // Testcontainers — real Postgres + real MySQL + real Kafka for end-to-end throughput jmh(libs.testcontainersPostgresql) + jmh(libs.testcontainersMysql) jmh(libs.testcontainersKafka) // DB driver + schema jmh(libs.postgresql) + jmh(libs.mysql) jmh(libs.liquibaseCore) // Kafka clients (provides MockProducer for microbenchmarks) diff --git a/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/MysqlOutboxStoreUpdateBatchBenchmark.kt b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/MysqlOutboxStoreUpdateBatchBenchmark.kt new file mode 100644 index 0000000..3ec2ada --- /dev/null +++ b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/MysqlOutboxStoreUpdateBatchBenchmark.kt @@ -0,0 +1,93 @@ +package com.softwaremill.okapi.benchmarks + +import com.softwaremill.okapi.benchmarks.support.MysqlBenchmarkSupport +import com.softwaremill.okapi.core.DeliveryInfo +import com.softwaremill.okapi.core.OutboxEntry +import com.softwaremill.okapi.core.OutboxMessage +import com.softwaremill.okapi.core.OutboxPublisher +import com.softwaremill.okapi.mysql.MysqlOutboxStore +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OperationsPerInvocation +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Param +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.TearDown +import java.time.Clock +import java.util.concurrent.TimeUnit + +/** + * MySQL counterpart of [OutboxStoreUpdateBatchBenchmark] (KOJAK-75), parameterized on + * `rewriteBatchedStatements` — Connector/J's `executeBatch()` sends one roundtrip per statement + * unless this JDBC URL property is set, unlike PgJDBC which pipelines batches natively. This + * benchmark quantifies the gap; see README "Performance" section. + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +open class MysqlOutboxStoreUpdateBatchBenchmark { + + @Param("false", "true") + lateinit var rewriteBatchedStatements: String + + private lateinit var mysql: MysqlBenchmarkSupport + private lateinit var store: MysqlOutboxStore + private lateinit var publisher: OutboxPublisher + private lateinit var processedEntries: List + + @Setup(Level.Trial) + fun setupTrial() { + mysql = MysqlBenchmarkSupport(rewriteBatchedStatements.toBoolean()).also { it.start() } + store = MysqlOutboxStore(mysql.jdbc) + publisher = OutboxPublisher(store, Clock.systemUTC()) + } + + @Setup(Level.Invocation) + fun setupInvocation() { + mysql.truncate() + mysql.jdbc.withTransaction { + repeat(TOTAL_ENTRIES) { + publisher.publish(OutboxMessage("bench.event", PAYLOAD), BenchDeliveryInfo) + } + } + val claimed = mysql.jdbc.withTransaction { store.claimPending(TOTAL_ENTRIES) } + val now = Clock.systemUTC().instant() + processedEntries = claimed.map { it.toDelivered(now) } + } + + @Benchmark + @OperationsPerInvocation(TOTAL_ENTRIES) + fun individualUpdates() { + mysql.jdbc.withTransaction { + processedEntries.forEach { store.updateAfterProcessing(it) } + } + } + + @Benchmark + @OperationsPerInvocation(TOTAL_ENTRIES) + fun batchUpdate() { + mysql.jdbc.withTransaction { + store.updateAfterProcessingBatch(processedEntries) + } + } + + @TearDown(Level.Trial) + fun teardown() { + mysql.stop() + } + + private object BenchDeliveryInfo : DeliveryInfo { + override val type = "bench" + + override fun serialize(): String = """{"type":"bench"}""" + } + + companion object { + const val TOTAL_ENTRIES = 1000 + private const val PAYLOAD = """{"orderId":"order-42","amount":100.50,"currency":"EUR"}""" + } +} diff --git a/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/OutboxStoreUpdateBatchBenchmark.kt b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/OutboxStoreUpdateBatchBenchmark.kt new file mode 100644 index 0000000..5173fd6 --- /dev/null +++ b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/OutboxStoreUpdateBatchBenchmark.kt @@ -0,0 +1,92 @@ +package com.softwaremill.okapi.benchmarks + +import com.softwaremill.okapi.benchmarks.support.PostgresBenchmarkSupport +import com.softwaremill.okapi.core.DeliveryInfo +import com.softwaremill.okapi.core.OutboxEntry +import com.softwaremill.okapi.core.OutboxMessage +import com.softwaremill.okapi.core.OutboxPublisher +import com.softwaremill.okapi.postgres.PostgresOutboxStore +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Level +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OperationsPerInvocation +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.TearDown +import java.time.Clock +import java.util.concurrent.TimeUnit + +/** + * Isolates the UPDATE phase of outbox processing (KOJAK-75): compares [TOTAL_ENTRIES] + * individual [PostgresOutboxStore.updateAfterProcessing] calls against a single + * [PostgresOutboxStore.updateAfterProcessingBatch] `executeBatch()` call. + * + * Unlike [KafkaThroughputBenchmark] / [HttpThroughputBenchmark], delivery itself is not + * measured here — entries are pre-computed as already-processed in [setupInvocation], so + * both benchmarks time only the persistence roundtrip(s). + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +open class OutboxStoreUpdateBatchBenchmark { + + private lateinit var postgres: PostgresBenchmarkSupport + private lateinit var store: PostgresOutboxStore + private lateinit var publisher: OutboxPublisher + private lateinit var processedEntries: List + + @Setup(Level.Trial) + fun setupTrial() { + postgres = PostgresBenchmarkSupport().also { it.start() } + store = PostgresOutboxStore(postgres.jdbc) + publisher = OutboxPublisher(store, Clock.systemUTC()) + } + + @Setup(Level.Invocation) + fun setupInvocation() { + postgres.truncate() + postgres.jdbc.withTransaction { + repeat(TOTAL_ENTRIES) { + publisher.publish(OutboxMessage("bench.event", PAYLOAD), BenchDeliveryInfo) + } + } + val claimed = postgres.jdbc.withTransaction { store.claimPending(TOTAL_ENTRIES) } + val now = Clock.systemUTC().instant() + processedEntries = claimed.map { it.toDelivered(now) } + } + + @Benchmark + @OperationsPerInvocation(TOTAL_ENTRIES) + fun individualUpdates() { + postgres.jdbc.withTransaction { + processedEntries.forEach { store.updateAfterProcessing(it) } + } + } + + @Benchmark + @OperationsPerInvocation(TOTAL_ENTRIES) + fun batchUpdate() { + postgres.jdbc.withTransaction { + store.updateAfterProcessingBatch(processedEntries) + } + } + + @TearDown(Level.Trial) + fun teardown() { + postgres.stop() + } + + private object BenchDeliveryInfo : DeliveryInfo { + override val type = "bench" + + override fun serialize(): String = """{"type":"bench"}""" + } + + companion object { + const val TOTAL_ENTRIES = 1000 + private const val PAYLOAD = """{"orderId":"order-42","amount":100.50,"currency":"EUR"}""" + } +} diff --git a/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/MysqlBenchmarkSupport.kt b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/MysqlBenchmarkSupport.kt new file mode 100644 index 0000000..c672992 --- /dev/null +++ b/okapi-benchmarks/src/jmh/kotlin/com/softwaremill/okapi/benchmarks/support/MysqlBenchmarkSupport.kt @@ -0,0 +1,75 @@ +package com.softwaremill.okapi.benchmarks.support + +import com.mysql.cj.jdbc.MysqlDataSource +import com.softwaremill.okapi.core.ConnectionProvider +import liquibase.Liquibase +import liquibase.database.DatabaseFactory +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.ClassLoaderResourceAccessor +import org.testcontainers.containers.MySQLContainer +import java.sql.Connection +import java.sql.DriverManager +import javax.sql.DataSource + +/** + * Brings up a real MySQL container, runs Liquibase migrations, and exposes a + * [PersistentConnectionProvider] holding one physical connection reused across the whole JMH + * trial — a raw per-invocation `dataSource.connection` (as [PostgresBenchmarkSupport] uses) pays + * MySQL's connection-handshake cost on every invocation, which swamps the roundtrip savings this + * benchmark exists to measure. One reused connection matches how okapi actually runs: a pooled + * connection borrowed once per scheduler tick, not re-established per `processNext()` call. + * + * [rewriteBatchedStatements] toggles the Connector/J JDBC URL property — see README "Performance" + * section: without it, `executeBatch()` sends one roundtrip per statement on MySQL. + */ +class MysqlBenchmarkSupport(private val rewriteBatchedStatements: Boolean) { + private val container = MySQLContainer("mysql:8.0") + lateinit var dataSource: DataSource + lateinit var jdbc: PersistentConnectionProvider + + fun start() { + container.start() + dataSource = MysqlDataSource().apply { + setURL("${container.jdbcUrl}?rewriteBatchedStatements=$rewriteBatchedStatements") + user = container.username + setPassword(container.password) + } + jdbc = PersistentConnectionProvider(dataSource.connection.apply { autoCommit = false }) + runLiquibase() + } + + fun stop() { + jdbc.close() + container.stop() + } + + fun truncate() { + jdbc.withTransaction { + jdbc.withConnection { conn -> + conn.createStatement().use { it.execute("DELETE FROM okapi_outbox") } + } + } + } + + private fun runLiquibase() { + val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password) + val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection)) + Liquibase("com/softwaremill/okapi/db/mysql/changelog.xml", ClassLoaderResourceAccessor(), db).use { it.update("") } + connection.close() + } +} + +/** Holds one physical [Connection] for the benchmark's lifetime instead of opening one per call. */ +class PersistentConnectionProvider(private val connection: Connection) : ConnectionProvider { + override fun withConnection(block: (Connection) -> T): T = block(connection) + + fun withTransaction(block: () -> T): T { + val result = block() + connection.commit() + return result + } + + fun close() { + connection.close() + } +} diff --git a/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxProcessor.kt b/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxProcessor.kt index 84cc82d..d2b0db9 100644 --- a/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxProcessor.kt +++ b/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxProcessor.kt @@ -40,10 +40,9 @@ class OutboxProcessor( val processed = entryProcessor.processBatch(claimed) val batchDuration = Duration.between(batchStart, clock.instant()) - processed.forEach { updated -> - store.updateAfterProcessing(updated) - notifyEntry(updated, batchDuration) - } + store.updateAfterProcessingBatch(processed) + processed.forEach { updated -> notifyEntry(updated, batchDuration) } + notifyBatch(processed.size, Duration.between(batchStart, clock.instant())) return processed.size } diff --git a/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxStore.kt b/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxStore.kt index d0d7ee2..07c60e5 100644 --- a/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxStore.kt +++ b/okapi-core/src/main/kotlin/com/softwaremill/okapi/core/OutboxStore.kt @@ -30,4 +30,11 @@ interface OutboxStore { /** Returns entry count per status. */ fun countByStatuses(): Map + + /** + * Updates a batch of entries after a delivery attempt in a single roundtrip where + * possible. Default loops over [updateAfterProcessing] one at a time; storage modules + * override this with a single JDBC `executeBatch()` call. + */ + fun updateAfterProcessingBatch(entries: List): List = entries.map { updateAfterProcessing(it) } } diff --git a/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxProcessorTest.kt b/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxProcessorTest.kt index b0ce32b..5668f7c 100644 --- a/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxProcessorTest.kt +++ b/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxProcessorTest.kt @@ -329,4 +329,43 @@ class OutboxProcessorTest : } } } + + given("processNext() with three pending entries") { + `when`("store overrides updateAfterProcessingBatch") { + val batchCalls = mutableListOf>() + val individualCalls = mutableListOf() + val batchAwareStore = object : OutboxStore { + override fun persist(entry: OutboxEntry) = entry + override fun claimPending(limit: Int): List = pendingEntries.take(limit) + override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry = entry.also { individualCalls += it } + override fun removeDeliveredBefore(time: Instant, limit: Int) = 0 + override fun findOldestCreatedAt(statuses: Set) = emptyMap() + override fun countByStatuses() = emptyMap() + + override fun updateAfterProcessingBatch(entries: List): List { + batchCalls += entries + return entries + } + } + + pendingEntries = listOf(stubEntry("a"), stubEntry("b"), stubEntry("c")) + val entryProcessor = OutboxEntryProcessor( + deliverer = stubDeliverer(DeliveryResult.Success), + retryPolicy = RetryPolicy(maxRetries = 3), + clock = fixedClock, + ) + val returnedCount = OutboxProcessor(batchAwareStore, entryProcessor).processNext(limit = 10) + + then("updateAfterProcessingBatch is called exactly once, with all processed entries") { + batchCalls.size shouldBe 1 + batchCalls.first().size shouldBe 3 + } + then("updateAfterProcessing is never called directly by the processor") { + individualCalls.size shouldBe 0 + } + then("processNext still returns the count of processed entries") { + returnedCount shouldBe 3 + } + } + } }) diff --git a/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxStoreTest.kt b/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxStoreTest.kt new file mode 100644 index 0000000..4742fec --- /dev/null +++ b/okapi-core/src/test/kotlin/com/softwaremill/okapi/core/OutboxStoreTest.kt @@ -0,0 +1,52 @@ +package com.softwaremill.okapi.core + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.time.Instant + +private val stubDeliveryInfo = object : DeliveryInfo { + override val type = "stub" + + override fun serialize(): String = """{"type":"stub"}""" +} + +private fun stubEntry(messageType: String = "test.event"): OutboxEntry = + OutboxEntry.createPending(OutboxMessage(messageType, "{}"), stubDeliveryInfo, Instant.EPOCH) + +class OutboxStoreTest : FunSpec({ + + test("default updateAfterProcessingBatch loops over updateAfterProcessing for each entry, in order") { + val updateCalls = mutableListOf() + val store = object : OutboxStore { + override fun persist(entry: OutboxEntry) = entry + override fun claimPending(limit: Int) = emptyList() + override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry = entry.also { updateCalls += it } + override fun removeDeliveredBefore(time: Instant, limit: Int) = 0 + override fun findOldestCreatedAt(statuses: Set) = emptyMap() + override fun countByStatuses() = emptyMap() + } + + val entries = listOf(stubEntry("a"), stubEntry("b"), stubEntry("c")) + val result = store.updateAfterProcessingBatch(entries) + + updateCalls shouldBe entries + result shouldBe entries + } + + test("default updateAfterProcessingBatch on an empty list calls updateAfterProcessing zero times") { + val updateCalls = mutableListOf() + val store = object : OutboxStore { + override fun persist(entry: OutboxEntry) = entry + override fun claimPending(limit: Int) = emptyList() + override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry = entry.also { updateCalls += it } + override fun removeDeliveredBefore(time: Instant, limit: Int) = 0 + override fun findOldestCreatedAt(statuses: Set) = emptyMap() + override fun countByStatuses() = emptyMap() + } + + val result = store.updateAfterProcessingBatch(emptyList()) + + updateCalls shouldBe emptyList() + result shouldBe emptyList() + } +}) diff --git a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/store/OutboxStoreContractTests.kt b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/store/OutboxStoreContractTests.kt index 2af0948..6550a91 100644 --- a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/store/OutboxStoreContractTests.kt +++ b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/store/OutboxStoreContractTests.kt @@ -251,4 +251,39 @@ fun FunSpec.outboxStoreContractTests( claimed shouldHaveSize 0 } + + test("[$dbName] updateAfterProcessingBatch persists mixed final statuses in a single call") { + val delivered = createTestEntry(now = Instant.parse("2024-01-01T00:00:00Z"), messageType = "type.delivered") + val retried = createTestEntry(now = Instant.parse("2024-01-01T00:00:01Z"), messageType = "type.retried") + val failed = createTestEntry(now = Instant.parse("2024-01-01T00:00:02Z"), messageType = "type.failed") + + jdbc.withTransaction { + store.persist(delivered) + store.persist(retried) + store.persist(failed) + } + + jdbc.withTransaction { + val claimed = store.claimPending(10) + val toDelivered = claimed.first { it.outboxId == delivered.outboxId } + .toDelivered(Instant.parse("2024-01-02T00:00:00Z")) + val toRetried = claimed.first { it.outboxId == retried.outboxId } + .retry(Instant.parse("2024-01-02T00:00:00Z"), "connection refused") + val toFailed = claimed.first { it.outboxId == failed.outboxId } + .toFailed(Instant.parse("2024-01-02T00:00:00Z"), "bad request") + + store.updateAfterProcessingBatch(listOf(toDelivered, toRetried, toFailed)) + } + + val counts = jdbc.withTransaction { store.countByStatuses() } + counts shouldContain (OutboxStatus.DELIVERED to 1L) + counts shouldContain (OutboxStatus.PENDING to 1L) + counts shouldContain (OutboxStatus.FAILED to 1L) + + val remaining = jdbc.withTransaction { store.claimPending(10) } + remaining shouldHaveSize 1 + remaining.first().messageType shouldBe "type.retried" + remaining.first().retries shouldBe 1 + remaining.first().lastError shouldBe "connection refused" + } } diff --git a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/MysqlTestSupport.kt b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/MysqlTestSupport.kt index 723a012..9e2a34b 100644 --- a/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/MysqlTestSupport.kt +++ b/okapi-integration-tests/src/test/kotlin/com/softwaremill/okapi/test/support/MysqlTestSupport.kt @@ -17,7 +17,9 @@ class MysqlTestSupport { fun start() { container.start() dataSource = MysqlDataSource().apply { - setURL(container.jdbcUrl) + // Without this, Connector/J's executeBatch() sends one roundtrip per statement, + // silently negating updateAfterProcessingBatch() — see README "Performance" section. + setURL("${container.jdbcUrl}?rewriteBatchedStatements=true") user = container.username setPassword(container.password) } diff --git a/okapi-mysql/src/main/kotlin/com/softwaremill/okapi/mysql/MysqlOutboxStore.kt b/okapi-mysql/src/main/kotlin/com/softwaremill/okapi/mysql/MysqlOutboxStore.kt index bde5871..b611a5b 100644 --- a/okapi-mysql/src/main/kotlin/com/softwaremill/okapi/mysql/MysqlOutboxStore.kt +++ b/okapi-mysql/src/main/kotlin/com/softwaremill/okapi/mysql/MysqlOutboxStore.kt @@ -5,8 +5,10 @@ import com.softwaremill.okapi.core.OutboxEntry import com.softwaremill.okapi.core.OutboxId import com.softwaremill.okapi.core.OutboxStatus import com.softwaremill.okapi.core.OutboxStore +import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.Timestamp +import java.sql.Types import java.time.Instant import java.util.UUID @@ -16,20 +18,8 @@ class MysqlOutboxStore( ) : OutboxStore { override fun persist(entry: OutboxEntry): OutboxEntry { - val sql = """ - INSERT INTO okapi_outbox (id, message_type, payload, delivery_type, status, created_at, updated_at, retries, last_attempt, last_error, delivery_metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - status = VALUES(status), - updated_at = VALUES(updated_at), - retries = VALUES(retries), - last_attempt = VALUES(last_attempt), - last_error = VALUES(last_error), - delivery_metadata = VALUES(delivery_metadata) - """.trimIndent() - connectionProvider.withConnection { conn -> - conn.prepareStatement(sql).use { stmt -> + conn.prepareStatement(upsertSql).use { stmt -> stmt.setString(1, entry.outboxId.raw.toString()) stmt.setString(2, entry.messageType) stmt.setString(3, entry.payload) @@ -78,7 +68,14 @@ class MysqlOutboxStore( } } - override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry = persist(entry) + override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry { + connectionProvider.withConnection { conn -> + conn.prepareStatement(updateSql).use { stmt -> + stmt.prepareForUpdateFrom(entry).executeUpdate() + } + } + return entry + } override fun removeDeliveredBefore(time: Instant, limit: Int): Int { val sql = """ @@ -139,6 +136,19 @@ class MysqlOutboxStore( return OutboxStatus.entries.associateWith { counts[it] ?: 0L } } + override fun updateAfterProcessingBatch(entries: List): List { + if (entries.isEmpty()) return entries + connectionProvider.withConnection { conn -> + conn.prepareStatement(updateSql).use { stmt -> + entries.forEach { entry -> + stmt.prepareForUpdateFrom(entry).addBatch() + } + stmt.executeBatch() + } + } + return entries + } + private fun ResultSet.toOutboxEntry(): OutboxEntry = OutboxEntry( outboxId = OutboxId(UUID.fromString(getString("id"))), messageType = getString("message_type"), @@ -152,4 +162,39 @@ class MysqlOutboxStore( lastError = getString("last_error"), deliveryMetadata = getString("delivery_metadata"), ) + + private fun PreparedStatement.prepareForUpdateFrom(entry: OutboxEntry): PreparedStatement { + setString(1, entry.status.name) + setTimestamp(2, Timestamp.from(entry.updatedAt)) + setInt(3, entry.retries) + if (entry.lastAttempt != null) { + setTimestamp(4, Timestamp.from(entry.lastAttempt)) + } else { + setNull(4, Types.TIMESTAMP) + } + if (entry.lastError != null) setString(5, entry.lastError) else setNull(5, Types.VARCHAR) + // CHAR(36) column — MySQL has no native UUID type, so the id travels as a string. + setString(6, entry.outboxId.raw.toString()) + return this + } + + companion object { + private val upsertSql = """ + INSERT INTO okapi_outbox (id, message_type, payload, delivery_type, status, created_at, updated_at, retries, last_attempt, last_error, delivery_metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + updated_at = VALUES(updated_at), + retries = VALUES(retries), + last_attempt = VALUES(last_attempt), + last_error = VALUES(last_error), + delivery_metadata = VALUES(delivery_metadata) + """.trimIndent() + + private val updateSql = """ + UPDATE okapi_outbox + SET status = ?, updated_at = ?, retries = ?, last_attempt = ?, last_error = ? + WHERE id = ? + """.trimIndent() + } } diff --git a/okapi-postgres/src/main/kotlin/com/softwaremill/okapi/postgres/PostgresOutboxStore.kt b/okapi-postgres/src/main/kotlin/com/softwaremill/okapi/postgres/PostgresOutboxStore.kt index 4bff6d3..0d23a71 100644 --- a/okapi-postgres/src/main/kotlin/com/softwaremill/okapi/postgres/PostgresOutboxStore.kt +++ b/okapi-postgres/src/main/kotlin/com/softwaremill/okapi/postgres/PostgresOutboxStore.kt @@ -5,8 +5,10 @@ import com.softwaremill.okapi.core.OutboxEntry import com.softwaremill.okapi.core.OutboxId import com.softwaremill.okapi.core.OutboxStatus import com.softwaremill.okapi.core.OutboxStore +import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.Timestamp +import java.sql.Types import java.time.Instant import java.util.UUID @@ -16,20 +18,8 @@ class PostgresOutboxStore( ) : OutboxStore { override fun persist(entry: OutboxEntry): OutboxEntry { - val sql = """ - INSERT INTO okapi_outbox (id, message_type, payload, delivery_type, status, created_at, updated_at, retries, last_attempt, last_error, delivery_metadata) - VALUES (?::uuid, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb) - ON CONFLICT (id) DO UPDATE SET - status = EXCLUDED.status, - updated_at = EXCLUDED.updated_at, - retries = EXCLUDED.retries, - last_attempt = EXCLUDED.last_attempt, - last_error = EXCLUDED.last_error, - delivery_metadata = EXCLUDED.delivery_metadata - """.trimIndent() - connectionProvider.withConnection { conn -> - conn.prepareStatement(sql).use { stmt -> + conn.prepareStatement(insertSql).use { stmt -> stmt.setString(1, entry.outboxId.raw.toString()) stmt.setString(2, entry.messageType) stmt.setString(3, entry.payload) @@ -74,7 +64,14 @@ class PostgresOutboxStore( } } - override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry = persist(entry) + override fun updateAfterProcessing(entry: OutboxEntry): OutboxEntry { + connectionProvider.withConnection { conn -> + conn.prepareStatement(updateSql).use { stmt -> + stmt.prepareForUpdateFrom(entry).executeUpdate() + } + } + return entry + } override fun removeDeliveredBefore(time: Instant, limit: Int): Int { val sql = """ @@ -133,6 +130,19 @@ class PostgresOutboxStore( return OutboxStatus.entries.associateWith { counts[it] ?: 0L } } + override fun updateAfterProcessingBatch(entries: List): List { + if (entries.isEmpty()) return entries + connectionProvider.withConnection { conn -> + conn.prepareStatement(updateSql).use { stmt -> + entries.forEach { entry -> + stmt.prepareForUpdateFrom(entry).addBatch() + } + stmt.executeBatch() + } + } + return entries + } + private fun ResultSet.toOutboxEntry(): OutboxEntry = OutboxEntry( outboxId = OutboxId(UUID.fromString(getString("id"))), messageType = getString("message_type"), @@ -146,4 +156,41 @@ class PostgresOutboxStore( lastError = getString("last_error"), deliveryMetadata = getString("delivery_metadata"), ) + + private fun PreparedStatement.prepareForUpdateFrom(entry: OutboxEntry): PreparedStatement { + setString(1, entry.status.name) + setTimestamp(2, Timestamp.from(entry.updatedAt)) + setInt(3, entry.retries) + if (entry.lastAttempt != null) { + setTimestamp( + 4, + Timestamp.from(entry.lastAttempt), + ) + } else { + setNull(4, Types.TIMESTAMP) + } + if (entry.lastError != null) setString(5, entry.lastError) else setNull(5, Types.VARCHAR) + setObject(6, entry.outboxId.raw) + return this + } + + companion object { + private val insertSql = """ + INSERT INTO okapi_outbox (id, message_type, payload, delivery_type, status, created_at, updated_at, retries, last_attempt, last_error, delivery_metadata) + VALUES (?::uuid, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at, + retries = EXCLUDED.retries, + last_attempt = EXCLUDED.last_attempt, + last_error = EXCLUDED.last_error, + delivery_metadata = EXCLUDED.delivery_metadata + """.trimIndent() + + private val updateSql = """ + UPDATE okapi_outbox + SET status = ?, updated_at = ?, retries = ?, last_attempt = ?, last_error = ? + WHERE id = ? + """.trimIndent() + } }