diff --git a/.gitignore b/.gitignore index 16cd4022809..4337a5bb0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,3 @@ __pycache__/ # Local agent configuration /.claude/ - -# Superpowers -docs/superpowers/ diff --git a/example/example-domain/src/main/kotlin/me/ahoo/wow/example/domain/cart/CartState.kt b/example/example-domain/src/main/kotlin/me/ahoo/wow/example/domain/cart/CartState.kt index fdea9dacae4..1a7209ada43 100644 --- a/example/example-domain/src/main/kotlin/me/ahoo/wow/example/domain/cart/CartState.kt +++ b/example/example-domain/src/main/kotlin/me/ahoo/wow/example/domain/cart/CartState.kt @@ -21,26 +21,35 @@ import me.ahoo.wow.example.api.cart.CartQuantityChanged import me.ahoo.wow.example.api.cart.ICartInfo class CartState(val id: String) : ICartInfo { - override var items: List = listOf() + override var items: List = ArrayList() private set + @Suppress("UNCHECKED_CAST") + private fun mutableItems(): MutableList { + if (items is MutableList<*>) { + return items as MutableList + } + val mutableItems = ArrayList(items) + items = mutableItems + return mutableItems + } + @OnSourcing fun onCartItemAdded(cartItemAdded: CartItemAdded) { - items = items + cartItemAdded.added + mutableItems().add(cartItemAdded.added) } @OnSourcing fun onCartItemRemoved(cartItemRemoved: CartItemRemoved) { - items = items.filter { !cartItemRemoved.productIds.contains(it.productId) } + mutableItems().removeAll { cartItemRemoved.productIds.contains(it.productId) } } @OnSourcing fun onCartQuantityChanged(cartQuantityChanged: CartQuantityChanged) { - items = items.map { - if (it.productId == cartQuantityChanged.changed.productId) { - cartQuantityChanged.changed - } else { - it + val mutableItems = mutableItems() + for (index in mutableItems.indices) { + if (mutableItems[index].productId == cartQuantityChanged.changed.productId) { + mutableItems[index] = cartQuantityChanged.changed } } } diff --git a/wow-benchmarks/build.gradle.kts b/wow-benchmarks/build.gradle.kts index dd8fa728d1f..c499382830b 100644 --- a/wow-benchmarks/build.gradle.kts +++ b/wow-benchmarks/build.gradle.kts @@ -1,5 +1,6 @@ -import java.time.LocalDate -import java.util.zip.ZipFile +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.JavaExec plugins { alias(libs.plugins.ksp) @@ -18,119 +19,7 @@ dependencies { kapt(libs.jmh.generator.annprocess) } -/** - * Merge all META-INF/wow-metadata.json from the runtime classpath into a single valid JSON. - * Without this, the JMH JAR contains duplicate entries that concatenate into invalid JSON, - * causing MetadataSearcher to fail silently at runtime. - * - * Aggregate entries are deep-merged (non-null values take precedence) to avoid the API module's - * type=null overwriting the domain module's type=full.class.Name. - */ -val mergedWowMetadata = layout.buildDirectory.file("tmp/wow-metadata-merged/META-INF/wow-metadata.json") -val mergeWowMetadata = tasks.register("mergeWowMetadata") { - description = "Merge all wow-metadata.json from classpath into a single file." - outputs.file(mergedWowMetadata) - inputs.files(configurations.jmhRuntimeClasspath) - doLast { - val mapper = com.fasterxml.jackson.databind.ObjectMapper() - val merged = mutableMapOf() - val metadataContents = mutableListOf() - - configurations.jmhRuntimeClasspath.get().resolve() - .filter { it.name.endsWith(".jar") || it.isDirectory } - .forEach { file -> - if (file.isDirectory) { - val meta = file.resolve("META-INF/wow-metadata.json") - if (meta.exists()) { - metadataContents.add(meta.readText()) - } - } else { - ZipFile(file).use { zip -> - zip.getEntry("META-INF/wow-metadata.json")?.let { entry -> - metadataContents.add(zip.getInputStream(entry).bufferedReader().readText()) - } - } - } - } - - for (text in metadataContents) { - try { - @Suppress("UNCHECKED_CAST") - val next = mapper.readValue(text, Map::class.java) as Map - @Suppress("UNCHECKED_CAST") - val contexts = next["contexts"] as? Map ?: continue - @Suppress("UNCHECKED_CAST") - val mergedContexts = merged.getOrPut("contexts") { mutableMapOf() } as MutableMap - for ((ctxName, ctxValue) in contexts) { - val existing = mergedContexts[ctxName] - if (existing == null) { - mergedContexts[ctxName] = ctxValue - } else { - @Suppress("UNCHECKED_CAST") - val existingMap = existing as MutableMap - @Suppress("UNCHECKED_CAST") - val newMap = ctxValue as Map - // Deep-merge aggregates (non-null values win) - @Suppress("UNCHECKED_CAST") - val existingAggregates = existingMap.getOrPut("aggregates") { mutableMapOf() } as MutableMap - @Suppress("UNCHECKED_CAST") - val newAggregates = newMap["aggregates"] as? Map ?: emptyMap() - for ((aggName, aggValue) in newAggregates) { - val existingAgg = existingAggregates[aggName] - if (existingAgg == null) { - existingAggregates[aggName] = aggValue - } else { - @Suppress("UNCHECKED_CAST") - val existingAggMap = existingAgg as MutableMap - @Suppress("UNCHECKED_CAST") - val newAggMap = aggValue as Map - for ((key, value) in newAggMap) { - if (value != null) { - val existingVal = existingAggMap[key] - if (existingVal == null || value is String) { - existingAggMap[key] = value - } else if (value is List<*>) { - @Suppress("UNCHECKED_CAST") - val existingList = (existingVal as? List ?: emptyList()).toMutableList() - @Suppress("UNCHECKED_CAST") - val newList = value as List - existingList.addAll(newList.filter { it !in existingList }) - existingAggMap[key] = existingList - } - } - } - } - } - // Merge scopes (union) - @Suppress("UNCHECKED_CAST") - val existingScopes = existingMap.getOrPut("scopes") { mutableListOf() } as MutableList - @Suppress("UNCHECKED_CAST") - val newScopes = newMap["scopes"] as? List ?: emptyList() - existingScopes.addAll(newScopes.filter { it !in existingScopes }) - } - } - } catch (_: Exception) { - // skip invalid metadata - } - } - - val outputFile = mergedWowMetadata.get().asFile - outputFile.parentFile.mkdirs() - mapper.writerWithDefaultPrettyPrinter().writeValue(outputFile, merged) - logger.lifecycle("Merged ${metadataContents.size} wow-metadata.json files into ${outputFile.absolutePath}") - } -} - -tasks.named("jmhJar") { - isZip64 = true - dependsOn(mergeWowMetadata) - // Exclude all wow-metadata.json from dependency JARs (they're duplicated) - exclude("META-INF/wow-metadata.json") - // Add the merged metadata file - from(mergedWowMetadata) { - into("META-INF") - } -} +apply(from = "gradle/jmh-packaging.gradle.kts") val benchmarkSmokeIncludes = listOf( "me.ahoo.wow.command.CommandFactoryBenchmark", @@ -172,6 +61,97 @@ tasks.register("benchmarkSmoke") { } } +val benchmarkInternalReport = layout.buildDirectory.file("results/jmh/internal.json") +val benchmarkExternalReport = layout.buildDirectory.file("results/jmh/external.json") +val benchmarkInternalHumanReport = layout.buildDirectory.file("reports/jmh/internal-human.txt") +val benchmarkExternalHumanReport = layout.buildDirectory.file("reports/jmh/external-human.txt") + +val benchmarkJvmArgs = listOf( + "-Xmx4g", + "-Xms4g", + "-XX:+UseG1GC", + "-XX:+UnlockDiagnosticVMOptions", + "-XX:+DebugNonSafepoints", + "-XX:+AlwaysPreTouch", +) + +fun benchmarkProfilerArgs(): List { + val asyncProfilerLib = file("/opt/async-profiler/lib/libasyncProfiler.dylib") + return buildList { + add("-prof") + add("gc") + add("-prof") + if (asyncProfilerLib.exists()) { + add("async:output=flamegraph;dir=build/profiling;event=cpu;libPath=${asyncProfilerLib.absolutePath}") + } else { + add("stack:lines=10;top=20") + } + } +} + +fun JavaExec.configureJmhBenchmarkRun( + includePattern: String, + resultsFile: Provider, + humanOutputFile: Provider, +) { + dependsOn(tasks.named("jmhJar")) + classpath(tasks.named("jmhJar").flatMap { it.archiveFile }) + mainClass.set("org.openjdk.jmh.Main") + args( + includePattern, + "-t", + "1", + "-wi", + "2", + "-w", + "5s", + "-i", + "3", + "-r", + "10s", + "-f", + "2", + "-foe", + "true", + "-rf", + "json", + "-rff", + resultsFile.get().asFile.absolutePath, + "-o", + humanOutputFile.get().asFile.absolutePath, + "-jvmArgs", + benchmarkJvmArgs.joinToString(" "), + ) + args(benchmarkProfilerArgs()) + outputs.file(resultsFile) + outputs.file(humanOutputFile) + outputs.upToDateWhen { false } + doFirst { + resultsFile.get().asFile.parentFile.mkdirs() + humanOutputFile.get().asFile.parentFile.mkdirs() + } +} + +tasks.register("benchmarkInternal") { + description = "Runs non-Mongo and non-Redis JMH benchmarks." + group = "benchmark" + configureJmhBenchmarkRun( + includePattern = """me\.ahoo\.wow\.(?!mongo\.|redis\.).*Benchmark.*""", + resultsFile = benchmarkInternalReport, + humanOutputFile = benchmarkInternalHumanReport, + ) +} + +tasks.register("benchmarkExternal") { + description = "Runs MongoDB and Redis JMH benchmarks." + group = "benchmark" + configureJmhBenchmarkRun( + includePattern = """me\.ahoo\.wow\.(mongo|redis)\..*Benchmark.*""", + resultsFile = benchmarkExternalReport, + humanOutputFile = benchmarkExternalHumanReport, + ) +} + jmh { zip64.set(true) includes.set(listOf(".*Benchmark.*")) @@ -206,177 +186,4 @@ jmh { }) } -val resultsDir = layout.projectDirectory.dir("results") -val baselineJson = resultsDir.file("baseline.json") -val latestJson = layout.buildDirectory.file("results/jmh/latest.json") -val readmeFile = layout.projectDirectory.file("README.md") - -tasks.register("generateBenchmarkReport") { - description = "Generate benchmark README.md from JMH JSON results." - group = "benchmark" - dependsOn(tasks.named("jmh")) - - doLast { - val resultsFile = latestJson.get().asFile - if (!resultsFile.exists()) { - throw GradleException("JMH results not found: ${resultsFile.absolutePath}. Run :wow-benchmarks:jmh first.") - } - - val mapper = com.fasterxml.jackson.databind.ObjectMapper() - val resultsText = resultsFile.readText() - @Suppress("UNCHECKED_CAST") - val jmhResults = mapper.readValue(resultsText, List::class.java) as List> - - val version = project.version.toString() - val date = LocalDate.now().toString() - val jvm = System.getProperty("java.vm.name") + " " + System.getProperty("java.vm.version") - val os = System.getProperty("os.name") + " " + System.getProperty("os.arch") - - val sb = StringBuilder() - sb.appendLine("# Benchmark Report") - sb.appendLine() - sb.appendLine("## Environment") - sb.appendLine("- **Version**: $version") - sb.appendLine("- **JVM**: $jvm") - sb.appendLine("- **OS**: $os") - sb.appendLine("- **Date**: $date") - sb.appendLine("- **JMH Config**: threads=1, warmup=2×5s, measurement=3×10s, fork=2") - sb.appendLine() - sb.appendLine("## Results") - sb.appendLine() - sb.appendLine("| Benchmark | Score | Error | Unit | gc.alloc.rate.norm |") - sb.appendLine("|-----------|-------|-------|------|-------------------|") - - for (result in jmhResults) { - val benchmark = result["benchmark"] as? String ?: continue - @Suppress("UNCHECKED_CAST") - val primaryMetric = result["primaryMetric"] as? Map ?: continue - val score = primaryMetric["score"] as? Double ?: continue - val scoreError = primaryMetric["scoreError"] as? Double ?: 0.0 - val unit = primaryMetric["scoreUnit"] as? String ?: "ops/s" - - var allocRateNorm = "—" - @Suppress("UNCHECKED_CAST") - val secondaryMetrics = result["secondaryMetrics"] as? Map> - if (secondaryMetrics != null) { - val gcAlloc = secondaryMetrics["gc.alloc.rate.norm"] - allocRateNorm = String.format("%.1f B/op", gcAlloc?.get("score") as? Double ?: 0.0) - } - - val parts = benchmark.split(".") - val shortName = if (parts.size >= 2) "${parts[parts.size - 2]}.${parts.last()}" else benchmark - sb.appendLine("| $shortName | ${String.format("%.2f", score)} | ±${String.format("%.2f", scoreError)} | $unit | $allocRateNorm |") - } - - readmeFile.asFile.writeText(sb.toString()) - logger.lifecycle("Benchmark report generated: ${readmeFile.asFile.absolutePath}") - } -} - -tasks.register("benchmarkCompare") { - description = "Compare latest benchmark results against baseline." - group = "benchmark" - - doLast { - val latestFile = latestJson.get().asFile - val baselineFile = baselineJson.asFile - - if (!baselineFile.exists()) { - throw GradleException("Baseline not found: ${baselineFile.absolutePath}. Run :wow-benchmarks:updateBaseline first.") - } - if (!latestFile.exists()) { - throw GradleException("Latest results not found: ${latestFile.absolutePath}. Run :wow-benchmarks:jmh first.") - } - - val mapper = com.fasterxml.jackson.databind.ObjectMapper() - - fun parseScores(file: java.io.File): Map { - @Suppress("UNCHECKED_CAST") - val results = mapper.readValue(file, List::class.java) as List> - return results.associate { result -> - val benchmark = result["benchmark"] as String - @Suppress("UNCHECKED_CAST") - val params = result["params"] as? Map - val key = if (params != null && params.isNotEmpty()) { - "$benchmark(${params.entries.joinToString(",") { "${it.key}=${it.value}" }})" - } else { - benchmark - } - @Suppress("UNCHECKED_CAST") - val primaryMetric = result["primaryMetric"] as Map - key to (primaryMetric["score"] as Double) - } - } - - val baseline = parseScores(baselineFile) - val latest = parseScores(latestFile) - val allBenchmarks = (baseline.keys + latest.keys).sorted() - - var regressions = 0 - var improvements = 0 - - println() - println("## Benchmark Comparison") - println() - println("| Benchmark | Baseline | Current | Δ% | Status |") - println("|-----------|----------|---------|----|--------|") - - for (benchmark in allBenchmarks) { - val baseScore = baseline[benchmark] - val latestScore = latest[benchmark] - val parts = benchmark.split("(")[0].split(".") - val shortName = if (parts.size >= 2) "${parts[parts.size - 2]}.${parts.last()}" else benchmark - val paramSuffix = if ("(" in benchmark) " ${benchmark.substringAfter("(").substringBefore(")")}" else "" - - val displayName = "$shortName$paramSuffix" - - if (baseScore == null) { - println("| $displayName | — | ${String.format("%.2f", latestScore)} | NEW | 🆕 |") - continue - } - if (latestScore == null) { - println("| $displayName | ${String.format("%.2f", baseScore)} | — | REMOVED | ⚠️ |") - continue - } - - val changePercent = ((latestScore - baseScore) / baseScore) * 100 - val status = when { - changePercent < -10.0 -> { - regressions++ - "🔴 REGRESSION" - } - changePercent > 10.0 -> { - improvements++ - "🟢 IMPROVED" - } - else -> "✅" - } - - println("| $displayName | ${String.format("%.2f", baseScore)} | ${String.format("%.2f", latestScore)} | ${String.format("%+.1f%%", changePercent)} | $status |") - } - - println() - println("**Summary:** $regressions regression(s), $improvements improvement(s), ${allBenchmarks.size - regressions - improvements} stable") - - if (regressions > 0) { - throw GradleException("Benchmark regressions detected: $regressions") - } - } -} - -tasks.register("updateBaseline") { - description = "Copy latest benchmark results as the new baseline." - group = "benchmark" - - doLast { - val latestFile = latestJson.get().asFile - val baselineFile = baselineJson.asFile - - if (!latestFile.exists()) { - throw GradleException("Latest results not found: ${latestFile.absolutePath}. Run :wow-benchmarks:jmh first.") - } - - latestFile.copyTo(baselineFile, overwrite = true) - logger.lifecycle("Baseline updated: ${baselineFile.absolutePath}") - } -} +apply(from = "gradle/benchmark-reporting.gradle.kts") diff --git a/wow-benchmarks/gradle/benchmark-reporting.gradle.kts b/wow-benchmarks/gradle/benchmark-reporting.gradle.kts new file mode 100644 index 00000000000..5489523d22a --- /dev/null +++ b/wow-benchmarks/gradle/benchmark-reporting.gradle.kts @@ -0,0 +1,477 @@ +import groovy.json.JsonSlurper +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import java.time.Instant +import java.time.LocalDate +import java.util.Locale + +val resultsDir = layout.projectDirectory.dir("results") +val baselineJson = resultsDir.file("baseline.json") +val latestJson = layout.buildDirectory.file("results/jmh/latest.json") +val readmeFile = layout.projectDirectory.file("README.md") + +val benchmarkInternalReport = layout.buildDirectory.file("results/jmh/internal.json") +val benchmarkExternalReport = layout.buildDirectory.file("results/jmh/external.json") + +data class BenchmarkResultGroup( + val name: String, + val command: String, + val resultFile: Provider, + val required: Boolean = true, +) + +data class BenchmarkGroupReport( + val group: BenchmarkResultGroup, + val rows: List, + val sourceRowCount: Int = rows.size, + val unavailableReason: String? = null, +) + +data class ParsedBenchmarkResult( + val group: String, + val benchmark: String, + val displayName: String, + val score: Double, + val scoreError: Double?, + val unit: String, + val allocationBytesPerOp: Double?, + val allocationErrorBytesPerOp: Double?, +) + +fun shortBenchmarkName(benchmark: String): String { + val parts = benchmark.split(".") + return if (parts.size >= 2) { + "${parts[parts.size - 2]}.${parts.last()}" + } else { + benchmark + } +} + +fun benchmarkDisplayName(result: Map<*, *>): String { + val benchmark = result["benchmark"] as String + @Suppress("UNCHECKED_CAST") + val params = result["params"] as? Map<*, *> + if (params.isNullOrEmpty()) { + return shortBenchmarkName(benchmark) + } + val paramText = params.entries.sortedBy { it.key.toString() } + .joinToString(", ") { "${it.key}=${it.value}" } + return "${shortBenchmarkName(benchmark)} ($paramText)" +} + +fun parseMetricNumber(value: Any?): Double? { + val parsed = when (value) { + is Number -> value.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } ?: return null + return parsed.takeIf { it.isFinite() } +} + +fun benchmarkResultRowException( + group: BenchmarkResultGroup, + resultFile: java.io.File, + rowIndex: Int, + message: String, +): GradleException { + return GradleException( + "Invalid JMH result row for ${group.name} at index $rowIndex in ${resultFile.absolutePath}: $message" + ) +} + +fun parseBenchmarkGroup( + parser: JsonSlurper, + group: BenchmarkResultGroup, +): BenchmarkGroupReport { + val resultFile = group.resultFile.get().asFile + if (!resultFile.exists()) { + if (!group.required) { + return BenchmarkGroupReport( + group = group, + rows = emptyList(), + unavailableReason = "Result file was not present. Run ${group.command} when the required service is available.", + ) + } + throw GradleException( + "JMH results not found for ${group.name}: ${resultFile.absolutePath}. Run ${group.command} first." + ) + } + val resultsText = resultFile.readText() + if (resultsText.isBlank()) { + throw GradleException("JMH results are empty for ${group.name}: ${resultFile.absolutePath}") + } + @Suppress("UNCHECKED_CAST") + val results = parser.parseText(resultsText) as List<*> + if (results.isEmpty()) { + throw GradleException("JMH results contain no benchmarks for ${group.name}: ${resultFile.absolutePath}") + } + val rows = results.mapIndexed { rowIndex, rawResult -> + val result = rawResult as? Map<*, *> ?: throw benchmarkResultRowException( + group = group, + resultFile = resultFile, + rowIndex = rowIndex, + message = "expected row to be a JSON object.", + ) + val benchmark = result["benchmark"] as? String ?: throw benchmarkResultRowException( + group = group, + resultFile = resultFile, + rowIndex = rowIndex, + message = "missing benchmark.", + ) + val primaryMetric = result["primaryMetric"] as? Map<*, *> ?: throw benchmarkResultRowException( + group = group, + resultFile = resultFile, + rowIndex = rowIndex, + message = "missing primaryMetric.", + ) + val score = parseMetricNumber(primaryMetric["score"]) ?: throw benchmarkResultRowException( + group = group, + resultFile = resultFile, + rowIndex = rowIndex, + message = "missing or unusable primaryMetric.score.", + ) + val scoreError = parseMetricNumber(primaryMetric["scoreError"]) + val unit = primaryMetric["scoreUnit"] as? String ?: "ops/s" + val secondaryMetrics = result["secondaryMetrics"] as? Map<*, *> + val allocationMetric = secondaryMetrics?.get("gc.alloc.rate.norm") as? Map<*, *> + val allocationBytesPerOp = parseMetricNumber(allocationMetric?.get("score")) + val allocationErrorBytesPerOp = parseMetricNumber(allocationMetric?.get("scoreError")) + ParsedBenchmarkResult( + group = group.name, + benchmark = benchmark, + displayName = benchmarkDisplayName(result), + score = score, + scoreError = scoreError, + unit = unit, + allocationBytesPerOp = allocationBytesPerOp, + allocationErrorBytesPerOp = allocationErrorBytesPerOp, + ) + } + return BenchmarkGroupReport(group = group, rows = rows, sourceRowCount = results.size) +} + +fun formatScoreError(scoreError: Double?): String { + return scoreError?.let { "+/-${String.format(Locale.US, "%.2f", it)}" } ?: "-" +} + +fun formatAllocationBytes(allocationBytesPerOp: Double?): String { + return allocationBytesPerOp?.let { String.format(Locale.US, "%.1f B/op", it) } ?: "-" +} + +fun formatAllocationError(allocationErrorBytesPerOp: Double?): String { + return allocationErrorBytesPerOp?.let { "+/-${String.format(Locale.US, "%.1f B/op", it)}" } ?: "-" +} + +fun StringBuilder.appendBenchmarkTable(rows: List) { + appendLine("| Benchmark | Score | Error | Unit | gc.alloc.rate.norm |") + appendLine("|-----------|-------|-------|------|-------------------|") + rows.sortedBy { it.displayName }.forEach { row -> + appendLine( + "| ${row.displayName} | ${String.format(Locale.US, "%.2f", row.score)} | " + + "${formatScoreError(row.scoreError)} | ${row.unit} | ${formatAllocationBytes(row.allocationBytesPerOp)} |" + ) + } +} + +fun StringBuilder.appendThroughputBottlenecks(rows: List) { + appendLine("| Benchmark | Score | Error | Unit |") + appendLine("|-----------|-------|-------|------|") + rows.filter { it.unit.contains("ops", ignoreCase = true) } + .sortedBy { it.score } + .take(10) + .forEach { row -> + appendLine( + "| ${row.group}: ${row.displayName} | ${String.format(Locale.US, "%.2f", row.score)} | " + + "${formatScoreError(row.scoreError)} | ${row.unit} |" + ) + } +} + +fun StringBuilder.appendAllocationBottlenecks(rows: List) { + appendLine("| Benchmark | Allocation | Error | Score | Unit |") + appendLine("|-----------|------------|-------|-------|------|") + rows.filter { it.allocationBytesPerOp != null } + .sortedByDescending { it.allocationBytesPerOp } + .take(10) + .forEach { row -> + appendLine( + "| ${row.group}: ${row.displayName} | " + + "${formatAllocationBytes(row.allocationBytesPerOp)} | " + + "${formatAllocationError(row.allocationErrorBytesPerOp)} | " + + "${String.format(Locale.US, "%.2f", row.score)} | ${row.unit} |" + ) + } +} + +fun renderGroupedBenchmarkReport( + groups: List, + version: String, +): String { + val parser = JsonSlurper() + val parsedGroups = groups.map { parseBenchmarkGroup(parser, it) } + val allRows = parsedGroups.flatMap { it.rows } + if (allRows.isEmpty()) { + throw GradleException("No benchmark rows were available for grouped report generation.") + } + val sb = StringBuilder() + sb.appendLine("# Grouped Benchmark Report") + sb.appendLine() + sb.appendLine("## Environment") + sb.appendLine("- **Version**: $version") + sb.appendLine("- **JVM**: ${System.getProperty("java.vm.name")} ${System.getProperty("java.vm.version")}") + sb.appendLine("- **OS**: ${System.getProperty("os.name")} ${System.getProperty("os.arch")}") + sb.appendLine("- **Date**: ${LocalDate.now()}") + sb.appendLine("- **JMH Config**: threads=1, warmup=2x5s, measurement=3x10s, fork=2") + sb.appendLine() + sb.appendLine("## Bottlenecks") + sb.appendLine() + sb.appendLine("### Lowest Throughput") + sb.appendLine() + sb.appendThroughputBottlenecks(allRows) + sb.appendLine() + sb.appendLine("### Highest Allocation") + sb.appendLine() + sb.appendAllocationBottlenecks(allRows) + sb.appendLine() + parsedGroups.filter { it.rows.isNotEmpty() }.forEach { groupReport -> + sb.appendLine("### ${groupReport.group.name} Lowest Throughput") + sb.appendLine() + sb.appendThroughputBottlenecks(groupReport.rows) + sb.appendLine() + sb.appendLine("### ${groupReport.group.name} Highest Allocation") + sb.appendLine() + sb.appendAllocationBottlenecks(groupReport.rows) + sb.appendLine() + } + parsedGroups.forEach { groupReport -> + val group = groupReport.group + val rows = groupReport.rows + val resultFile = group.resultFile.get().asFile + sb.appendLine("## ${group.name} Results") + sb.appendLine() + sb.appendLine("- **Command**: `${group.command}`") + sb.appendLine("- **Result File**: `${resultFile.absolutePath}`") + if (resultFile.exists()) { + sb.appendLine("- **Last Modified**: ${Instant.ofEpochMilli(resultFile.lastModified())}") + } + sb.appendLine("- **Source Row Count**: ${groupReport.sourceRowCount}") + sb.appendLine("- **Parsed Row Count**: ${rows.size}") + sb.appendLine() + if (groupReport.unavailableReason != null) { + sb.appendLine(groupReport.unavailableReason) + } else { + sb.appendBenchmarkTable(rows) + } + sb.appendLine() + } + return sb.toString() +} + +tasks.register("generateBenchmarkReport") { + description = "Generate benchmark README.md from JMH JSON results." + group = "benchmark" + dependsOn(tasks.named("jmh")) + + doLast { + val resultsFile = latestJson.get().asFile + if (!resultsFile.exists()) { + throw GradleException("JMH results not found: ${resultsFile.absolutePath}. Run :wow-benchmarks:jmh first.") + } + + val parser = JsonSlurper() + val resultsText = resultsFile.readText() + @Suppress("UNCHECKED_CAST") + val jmhResults = parser.parseText(resultsText) as List> + + val version = project.version.toString() + val date = LocalDate.now().toString() + val jvm = System.getProperty("java.vm.name") + " " + System.getProperty("java.vm.version") + val os = System.getProperty("os.name") + " " + System.getProperty("os.arch") + + val sb = StringBuilder() + sb.appendLine("# Benchmark Report") + sb.appendLine() + sb.appendLine("## Environment") + sb.appendLine("- **Version**: $version") + sb.appendLine("- **JVM**: $jvm") + sb.appendLine("- **OS**: $os") + sb.appendLine("- **Date**: $date") + sb.appendLine("- **JMH Config**: threads=1, warmup=2×5s, measurement=3×10s, fork=2") + sb.appendLine() + sb.appendLine("## Results") + sb.appendLine() + sb.appendLine("| Benchmark | Score | Error | Unit | gc.alloc.rate.norm |") + sb.appendLine("|-----------|-------|-------|------|-------------------|") + + for (result in jmhResults) { + val benchmark = result["benchmark"] as? String ?: continue + @Suppress("UNCHECKED_CAST") + val primaryMetric = result["primaryMetric"] as? Map ?: continue + val score = primaryMetric["score"] as? Double ?: continue + val scoreError = primaryMetric["scoreError"] as? Double ?: 0.0 + val unit = primaryMetric["scoreUnit"] as? String ?: "ops/s" + + var allocRateNorm = "—" + @Suppress("UNCHECKED_CAST") + val secondaryMetrics = result["secondaryMetrics"] as? Map> + if (secondaryMetrics != null) { + val gcAlloc = secondaryMetrics["gc.alloc.rate.norm"] + allocRateNorm = String.format(Locale.US, "%.1f B/op", gcAlloc?.get("score") as? Double ?: 0.0) + } + + val parts = benchmark.split(".") + val shortName = if (parts.size >= 2) "${parts[parts.size - 2]}.${parts.last()}" else benchmark + sb.appendLine( + "| $shortName | ${String.format(Locale.US, "%.2f", score)} | " + + "±${String.format(Locale.US, "%.2f", scoreError)} | $unit | $allocRateNorm |" + ) + } + + readmeFile.asFile.writeText(sb.toString()) + logger.lifecycle("Benchmark report generated: ${readmeFile.asFile.absolutePath}") + } +} + +val groupedBenchmarkReport = layout.buildDirectory.file("reports/jmh/grouped.md") + +tasks.register("generateGroupedBenchmarkReport") { + description = "Generate a grouped benchmark report from internal and external JMH JSON results." + group = "benchmark" + outputs.file(groupedBenchmarkReport) + outputs.upToDateWhen { false } + doLast { + val outputFile = groupedBenchmarkReport.get().asFile + outputFile.delete() + val report = renderGroupedBenchmarkReport( + groups = listOf( + BenchmarkResultGroup( + name = "Internal", + command = "./gradlew :wow-benchmarks:benchmarkInternal", + resultFile = benchmarkInternalReport, + ), + BenchmarkResultGroup( + name = "External", + command = "./gradlew :wow-benchmarks:benchmarkExternal", + resultFile = benchmarkExternalReport, + required = false, + ), + ), + version = project.version.toString(), + ) + outputFile.parentFile.mkdirs() + outputFile.writeText(report) + logger.lifecycle("Grouped benchmark report generated: ${outputFile.absolutePath}") + } +} + +tasks.register("benchmarkCompare") { + description = "Compare latest benchmark results against baseline." + group = "benchmark" + + doLast { + val latestFile = latestJson.get().asFile + val baselineFile = baselineJson.asFile + + if (!baselineFile.exists()) { + throw GradleException("Baseline not found: ${baselineFile.absolutePath}. Run :wow-benchmarks:updateBaseline first.") + } + if (!latestFile.exists()) { + throw GradleException("Latest results not found: ${latestFile.absolutePath}. Run :wow-benchmarks:jmh first.") + } + + val parser = JsonSlurper() + + fun parseScores(file: java.io.File): Map { + @Suppress("UNCHECKED_CAST") + val results = parser.parse(file) as List> + return results.associate { result -> + val benchmark = result["benchmark"] as String + @Suppress("UNCHECKED_CAST") + val params = result["params"] as? Map + val key = if (params != null && params.isNotEmpty()) { + "$benchmark(${params.entries.joinToString(",") { "${it.key}=${it.value}" }})" + } else { + benchmark + } + @Suppress("UNCHECKED_CAST") + val primaryMetric = result["primaryMetric"] as Map + key to (primaryMetric["score"] as Double) + } + } + + val baseline = parseScores(baselineFile) + val latest = parseScores(latestFile) + val allBenchmarks = (baseline.keys + latest.keys).sorted() + + var regressions = 0 + var improvements = 0 + + println() + println("## Benchmark Comparison") + println() + println("| Benchmark | Baseline | Current | Δ% | Status |") + println("|-----------|----------|---------|----|--------|") + + for (benchmark in allBenchmarks) { + val baseScore = baseline[benchmark] + val latestScore = latest[benchmark] + val parts = benchmark.split("(")[0].split(".") + val shortName = if (parts.size >= 2) "${parts[parts.size - 2]}.${parts.last()}" else benchmark + val paramSuffix = if ("(" in benchmark) " ${benchmark.substringAfter("(").substringBefore(")")}" else "" + + val displayName = "$shortName$paramSuffix" + + if (baseScore == null) { + println("| $displayName | — | ${String.format(Locale.US, "%.2f", latestScore)} | NEW | 🆕 |") + continue + } + if (latestScore == null) { + println("| $displayName | ${String.format(Locale.US, "%.2f", baseScore)} | — | REMOVED | ⚠️ |") + continue + } + + val changePercent = ((latestScore - baseScore) / baseScore) * 100 + val status = when { + changePercent < -10.0 -> { + regressions++ + "🔴 REGRESSION" + } + changePercent > 10.0 -> { + improvements++ + "🟢 IMPROVED" + } + else -> "✅" + } + + println( + "| $displayName | ${String.format(Locale.US, "%.2f", baseScore)} | " + + "${String.format(Locale.US, "%.2f", latestScore)} | " + + "${String.format(Locale.US, "%+.1f%%", changePercent)} | $status |" + ) + } + + println() + println("**Summary:** $regressions regression(s), $improvements improvement(s), ${allBenchmarks.size - regressions - improvements} stable") + + if (regressions > 0) { + throw GradleException("Benchmark regressions detected: $regressions") + } + } +} + +tasks.register("updateBaseline") { + description = "Copy latest benchmark results as the new baseline." + group = "benchmark" + + doLast { + val latestFile = latestJson.get().asFile + val baselineFile = baselineJson.asFile + + if (!latestFile.exists()) { + throw GradleException("Latest results not found: ${latestFile.absolutePath}. Run :wow-benchmarks:jmh first.") + } + + latestFile.copyTo(baselineFile, overwrite = true) + logger.lifecycle("Baseline updated: ${baselineFile.absolutePath}") + } +} diff --git a/wow-benchmarks/gradle/jmh-packaging.gradle.kts b/wow-benchmarks/gradle/jmh-packaging.gradle.kts new file mode 100644 index 00000000000..69b1710cf27 --- /dev/null +++ b/wow-benchmarks/gradle/jmh-packaging.gradle.kts @@ -0,0 +1,189 @@ +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.util.zip.ZipFile + +val jmhRuntimeClasspath = configurations.named("jmhRuntimeClasspath") + +/** + * Merge all META-INF/wow-metadata.json from the runtime classpath into a single valid JSON. + * Without this, the JMH JAR contains duplicate entries that concatenate into invalid JSON, + * causing MetadataSearcher to fail silently at runtime. + * + * Aggregate entries are deep-merged (non-null values take precedence) to avoid the API module's + * type=null overwriting the domain module's type=full.class.Name. + */ +val mergedWowMetadata = layout.buildDirectory.file("tmp/wow-metadata-merged/META-INF/wow-metadata.json") +val mergeWowMetadata = tasks.register("mergeWowMetadata") { + description = "Merge all wow-metadata.json from classpath into a single file." + outputs.file(mergedWowMetadata) + inputs.files(jmhRuntimeClasspath) + doLast { + val parser = JsonSlurper() + val merged = mutableMapOf() + val metadataContents = mutableListOf() + + fun deepMutable(value: Any?): Any? = when (value) { + is Map<*, *> -> value.entries.associate { entry -> + entry.key.toString() to deepMutable(entry.value) + }.toMutableMap() + + is List<*> -> value.map { deepMutable(it) }.toMutableList() + else -> value + } + + jmhRuntimeClasspath.get().resolve() + .filter { it.name.endsWith(".jar") || it.isDirectory } + .forEach { file -> + if (file.isDirectory) { + val meta = file.resolve("META-INF/wow-metadata.json") + if (meta.exists()) { + metadataContents.add(meta.readText()) + } + } else { + ZipFile(file).use { zip -> + zip.getEntry("META-INF/wow-metadata.json")?.let { entry -> + metadataContents.add(zip.getInputStream(entry).bufferedReader().readText()) + } + } + } + } + + for (text in metadataContents) { + try { + @Suppress("UNCHECKED_CAST") + val next = parser.parseText(text) as Map + @Suppress("UNCHECKED_CAST") + val contexts = next["contexts"] as? Map ?: continue + @Suppress("UNCHECKED_CAST") + val mergedContexts = merged.getOrPut("contexts") { mutableMapOf() } as MutableMap + for ((ctxName, ctxValue) in contexts) { + val existing = mergedContexts[ctxName] + if (existing == null) { + mergedContexts[ctxName] = deepMutable(ctxValue)!! + } else { + @Suppress("UNCHECKED_CAST") + val existingMap = existing as MutableMap + @Suppress("UNCHECKED_CAST") + val newMap = ctxValue as Map + // Deep-merge aggregates (non-null values win) + @Suppress("UNCHECKED_CAST") + val existingAggregates = existingMap.getOrPut("aggregates") { mutableMapOf() } as MutableMap + @Suppress("UNCHECKED_CAST") + val newAggregates = newMap["aggregates"] as? Map ?: emptyMap() + for ((aggName, aggValue) in newAggregates) { + val existingAgg = existingAggregates[aggName] + if (existingAgg == null) { + existingAggregates[aggName] = deepMutable(aggValue)!! + } else { + @Suppress("UNCHECKED_CAST") + val existingAggMap = existingAgg as MutableMap + @Suppress("UNCHECKED_CAST") + val newAggMap = aggValue as Map + for ((key, value) in newAggMap) { + if (value != null) { + val existingVal = existingAggMap[key] + if (existingVal == null || value is String) { + existingAggMap[key] = value + } else if (value is List<*>) { + @Suppress("UNCHECKED_CAST") + val existingList = (existingVal as? List ?: emptyList()).toMutableList() + @Suppress("UNCHECKED_CAST") + val newList = value as List + existingList.addAll(newList.filter { it !in existingList }) + existingAggMap[key] = existingList + } + } + } + } + } + // Merge scopes (union) + @Suppress("UNCHECKED_CAST") + val existingScopes = existingMap.getOrPut("scopes") { mutableListOf() } as MutableList + @Suppress("UNCHECKED_CAST") + val newScopes = newMap["scopes"] as? List ?: emptyList() + existingScopes.addAll(newScopes.filter { it !in existingScopes }) + } + } + } catch (_: Exception) { + // skip invalid metadata + } + } + + val outputFile = mergedWowMetadata.get().asFile + outputFile.parentFile.mkdirs() + outputFile.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(merged))) + logger.lifecycle("Merged ${metadataContents.size} wow-metadata.json files into ${outputFile.absolutePath}") + } +} + +val mergedJmhServicesRoot = layout.buildDirectory.dir("tmp/jmh-services-merged") +val jmhServiceFilesToMerge = listOf( + "META-INF/services/tools.jackson.databind.JacksonModule", + "META-INF/services/me.ahoo.wow.id.GlobalIdGeneratorFactory", +) +val mergeJmhServices = tasks.register("mergeJmhServices") { + description = "Merge critical SPI service files from the JMH runtime classpath." + outputs.dir(mergedJmhServicesRoot) + inputs.files(jmhRuntimeClasspath) + inputs.files(jmhServiceFilesToMerge.map { layout.projectDirectory.file("src/jmh/resources/$it") }) + doLast { + val outputRoot = mergedJmhServicesRoot.get().asFile + outputRoot.deleteRecursively() + + fun MutableSet.addServiceProviders(text: String) { + text.lineSequence() + .map { it.substringBefore('#').trim() } + .filter { it.isNotEmpty() } + .forEach { add(it) } + } + + for (servicePath in jmhServiceFilesToMerge) { + val providers = linkedSetOf() + jmhRuntimeClasspath.get().resolve() + .filter { it.name.endsWith(".jar") || it.isDirectory } + .forEach { file -> + if (file.isDirectory) { + val serviceFile = file.resolve(servicePath) + if (serviceFile.exists()) { + providers.addServiceProviders(serviceFile.readText()) + } + } else { + ZipFile(file).use { zip -> + zip.getEntry(servicePath)?.let { entry -> + providers.addServiceProviders(zip.getInputStream(entry).bufferedReader().readText()) + } + } + } + } + + val localServiceFile = file("src/jmh/resources/$servicePath") + if (localServiceFile.exists()) { + providers.addServiceProviders(localServiceFile.readText()) + } + + if (providers.isNotEmpty()) { + val outputFile = outputRoot.resolve(servicePath) + outputFile.parentFile.mkdirs() + outputFile.writeText(providers.joinToString(System.lineSeparator(), postfix = System.lineSeparator())) + } + } + } +} + +tasks.named("jmhJar") { + isZip64 = true + dependsOn(mergeWowMetadata) + dependsOn(mergeJmhServices) + // Exclude all wow-metadata.json from dependency JARs (they're duplicated) + exclude("META-INF/wow-metadata.json") + // Add the merged metadata file + from(mergedWowMetadata) { + into("META-INF") + } + eachFile { + if (path in jmhServiceFilesToMerge && !file.absolutePath.startsWith(mergedJmhServicesRoot.get().asFile.absolutePath)) { + exclude() + } + } + from(mergedJmhServicesRoot) +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/BenchmarkAggregateSchedulerSupplier.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/BenchmarkAggregateSchedulerSupplier.kt new file mode 100644 index 00000000000..a18a786ea42 --- /dev/null +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/BenchmarkAggregateSchedulerSupplier.kt @@ -0,0 +1,38 @@ +/* + * Copyright [2021-present] [ahoo wang (https://github.com/Ahoo-Wang)]. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ahoo.wow + +import me.ahoo.wow.api.modeling.NamedAggregate +import me.ahoo.wow.modeling.MaterializedNamedAggregate +import me.ahoo.wow.modeling.materialize +import me.ahoo.wow.scheduler.AggregateSchedulerSupplier +import reactor.core.publisher.Mono +import reactor.core.scheduler.Scheduler +import reactor.core.scheduler.Schedulers +import java.util.concurrent.ConcurrentHashMap + +class BenchmarkAggregateSchedulerSupplier : AggregateSchedulerSupplier { + private val schedulers: MutableMap = ConcurrentHashMap() + + override fun getOrInitialize(namedAggregate: NamedAggregate): Scheduler = + schedulers.computeIfAbsent(namedAggregate.materialize()) { + Schedulers.newParallel("CommandDispatcherBenchmark-${it.aggregateName}", Schedulers.DEFAULT_POOL_SIZE) + } + + override fun stopGracefully(): Mono = + Mono.fromRunnable { + schedulers.values.forEach(Scheduler::dispose) + schedulers.clear() + } +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/BenchmarkGlobalIdGeneratorFactory.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/BenchmarkGlobalIdGeneratorFactory.kt new file mode 100644 index 00000000000..3c57289c7ce --- /dev/null +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/BenchmarkGlobalIdGeneratorFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright [2021-present] [ahoo wang (https://github.com/Ahoo-Wang)]. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ahoo.wow + +import me.ahoo.cosid.cosid.ClockSyncCosIdGenerator +import me.ahoo.cosid.cosid.CosIdGenerator +import me.ahoo.cosid.cosid.Radix62CosIdGenerator +import me.ahoo.wow.id.GlobalIdGeneratorFactory + +class BenchmarkGlobalIdGeneratorFactory : GlobalIdGeneratorFactory { + override fun create(): CosIdGenerator { + return ClockSyncCosIdGenerator(Radix62CosIdGenerator(0)) + } +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/BloomFilterIdempotencyCheckerBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/BloomFilterIdempotencyCheckerBenchmark.kt index 8bd40d5ba2a..5160a64c8d0 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/BloomFilterIdempotencyCheckerBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/BloomFilterIdempotencyCheckerBenchmark.kt @@ -35,4 +35,4 @@ open class BloomFilterIdempotencyCheckerBenchmark { val result = idempotencyChecker.check(generateGlobalId()).block() blackhole.consume(result) } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/Commands.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/Commands.kt index 7252fd5f7ce..09cec58c002 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/Commands.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/command/Commands.kt @@ -19,18 +19,17 @@ import me.ahoo.wow.api.command.CommandMessage import me.ahoo.wow.example.api.cart.AddCartItem import me.ahoo.wow.example.domain.cart.Cart import me.ahoo.wow.example.domain.cart.CartState +import me.ahoo.wow.id.generateGlobalId import me.ahoo.wow.infra.idempotency.BloomFilterIdempotencyChecker import me.ahoo.wow.modeling.MaterializedNamedAggregate import me.ahoo.wow.modeling.annotation.aggregateMetadata import java.time.Duration -import java.util.concurrent.atomic.AtomicLong val cartAggregateMetadata by lazy { aggregateMetadata() } private val benchmarkCart = MaterializedNamedAggregate("example-service", "cart") -private val benchmarkIdSequence = AtomicLong() const val FIXED_AGGREGATE_ID = "benchmark-cart-fixed-id" fun createCommandMessage(): CommandMessage { @@ -78,7 +77,7 @@ private fun createCommandMessage( ) } -private fun nextBenchmarkId(): String = "benchmark-${benchmarkIdSequence.incrementAndGet()}" +private fun nextBenchmarkId(): String = generateGlobalId() fun createBloomFilterIdempotencyChecker(): BloomFilterIdempotencyChecker { return BloomFilterIdempotencyChecker(Duration.ofMinutes(1)) { diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/EventStreamFactoryBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/EventStreamFactoryBenchmark.kt index 14c76254142..c4c22e6e3d3 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/EventStreamFactoryBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/EventStreamFactoryBenchmark.kt @@ -26,4 +26,10 @@ open class EventStreamFactoryBenchmark { val eventStream = createEventStream() blackhole.consume(eventStream) } -} \ No newline at end of file + + @Benchmark + fun createSingleEventStream(blackhole: Blackhole) { + val eventStream = createSingleEventStream() + blackhole.consume(eventStream) + } +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/Events.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/Events.kt index b15caebe90f..e59acd0c854 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/Events.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/Events.kt @@ -26,4 +26,11 @@ fun createEventStream(): DomainEventStream { return listOf(event).toDomainEventStream( upstream = GivenInitializationCommand(cartAggregateMetadata.aggregateId()), ) -} \ No newline at end of file +} + +fun createSingleEventStream(): DomainEventStream { + val event = CartItemAdded(CartItem("productId")) + return event.toDomainEventStream( + upstream = GivenInitializationCommand(cartAggregateMetadata.aggregateId()), + ) +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/InMemoryEventStoreBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/InMemoryEventStoreBenchmark.kt index 5047760a2d7..15cecb974ea 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/InMemoryEventStoreBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/InMemoryEventStoreBenchmark.kt @@ -14,30 +14,32 @@ package me.ahoo.wow.eventsourcing import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.OperationsPerInvocation import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State -import org.openjdk.jmh.annotations.TearDown @State(Scope.Benchmark) open class InMemoryEventStoreBenchmark : AbstractEventStoreBenchmark() { + private companion object { + const val APPENDS_PER_INVOCATION = 1024 + } @Setup override fun setup() { super.setup() } - @TearDown - fun tearDown() { - setup() - } - override fun createEventStore(): EventStore { return InMemoryEventStore() } @Benchmark + @OperationsPerInvocation(APPENDS_PER_INVOCATION) override fun append() { - super.append() + repeat(APPENDS_PER_INVOCATION) { + super.append() + } + setup() } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/NoopEventStoreBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/NoopEventStoreBenchmark.kt index 345546abd28..06c0c3f8227 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/NoopEventStoreBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/NoopEventStoreBenchmark.kt @@ -15,10 +15,16 @@ package me.ahoo.wow.eventsourcing import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @State(Scope.Benchmark) open class NoopEventStoreBenchmark : AbstractEventStoreBenchmark() { + @Setup + override fun setup() { + super.setup() + } + override fun createEventStore(): EventStore { return NoopEventStore } @@ -28,4 +34,4 @@ open class NoopEventStoreBenchmark : AbstractEventStoreBenchmark() { override fun append() { super.append() } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/SnapshotBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/SnapshotBenchmark.kt index 78379faa52c..d037e61a301 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/SnapshotBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/eventsourcing/SnapshotBenchmark.kt @@ -30,7 +30,7 @@ import org.openjdk.jmh.infra.Blackhole @State(Scope.Benchmark) open class SnapshotBenchmark { - private lateinit var snapshotRepository: SnapshotRepository + private lateinit var snapshotLoadRepository: SnapshotRepository private lateinit var snapshotStrategy: VersionOffsetSnapshotStrategy private lateinit var stateEventExchange: SimpleStateEventExchange<*> private lateinit var aggregateId: me.ahoo.wow.api.modeling.AggregateId @@ -38,21 +38,20 @@ open class SnapshotBenchmark { @Setup fun setup() { aggregateId = cartAggregateMetadata.aggregateId() - snapshotRepository = InMemorySnapshotRepository() + snapshotLoadRepository = InMemorySnapshotRepository() snapshotStrategy = VersionOffsetSnapshotStrategy( versionOffset = 5, - snapshotRepository = snapshotRepository, + snapshotRepository = InMemorySnapshotRepository(), ) val aggregate = ConstructorStateAggregateFactory.create( cartAggregateMetadata.state, aggregateId, ) - val snapshot = SimpleSnapshot(aggregate) - snapshotRepository.save(snapshot).block() - val eventStream = createEventStream() val stateEvent = eventStream.toStateEvent(aggregate) + val snapshot = SimpleSnapshot(stateEvent) + snapshotLoadRepository.save(snapshot).block() stateEventExchange = SimpleStateEventExchange(stateEvent) } @@ -64,7 +63,7 @@ open class SnapshotBenchmark { @Benchmark fun snapshotLoad(blackhole: Blackhole) { - val snapshot = snapshotRepository.load>(aggregateId).block() + val snapshot = snapshotLoadRepository.load>(aggregateId).block() blackhole.consume(snapshot) } } diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandHandlingBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandHandlingBenchmark.kt index d045389409e..0f92da3bd57 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandHandlingBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandHandlingBenchmark.kt @@ -13,6 +13,9 @@ package me.ahoo.wow.hotpath +import me.ahoo.wow.command.SimpleServerCommandExchange +import me.ahoo.wow.eventsourcing.NoopEventStore +import me.ahoo.wow.modeling.command.SimpleCommandAggregateFactory import me.ahoo.wow.modeling.state.ConstructorStateAggregateFactory import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope @@ -21,6 +24,8 @@ import org.openjdk.jmh.infra.Blackhole @State(Scope.Benchmark) open class CommandHandlingBenchmark { + private val commandAggregateFactory = SimpleCommandAggregateFactory(NoopEventStore) + @Benchmark fun createAggregateAndHandle(blackhole: Blackhole) { val aggregate = ConstructorStateAggregateFactory.create( @@ -39,4 +44,35 @@ open class CommandHandlingBenchmark { ) blackhole.consume(aggregate) } + + @Benchmark + fun createCommandAggregate(blackhole: Blackhole) { + val commandMessage = HotPathFixture.createCommandMessage() + val stateAggregate = ConstructorStateAggregateFactory.create( + HotPathFixture.aggregateMetadata.state, + commandMessage.aggregateId, + ) + val commandAggregate = commandAggregateFactory.create( + HotPathFixture.aggregateMetadata, + stateAggregate, + ) + blackhole.consume(commandAggregate) + } + + @Benchmark + fun processCommandAggregate(blackhole: Blackhole) { + val commandMessage = HotPathFixture.createCommandMessage() + val stateAggregate = ConstructorStateAggregateFactory.create( + HotPathFixture.aggregateMetadata.state, + commandMessage.aggregateId, + ) + val commandAggregate = commandAggregateFactory.create( + HotPathFixture.aggregateMetadata, + stateAggregate, + ) + val eventStream = commandAggregate.process( + SimpleServerCommandExchange(commandMessage), + ).block() + blackhole.consume(eventStream) + } } diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandProcessingPipelineBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandProcessingPipelineBenchmark.kt index c611f3ae44c..26a5c97c0ad 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandProcessingPipelineBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/CommandProcessingPipelineBenchmark.kt @@ -13,20 +13,26 @@ package me.ahoo.wow.hotpath +import me.ahoo.wow.BenchmarkAggregateSchedulerSupplier +import me.ahoo.wow.api.modeling.AggregateId +import me.ahoo.wow.api.modeling.NamedTypedAggregate import me.ahoo.wow.command.CommandGateway import me.ahoo.wow.command.DefaultCommandGateway import me.ahoo.wow.command.InMemoryCommandBus import me.ahoo.wow.command.ServerCommandExchange +import me.ahoo.wow.command.SimpleServerCommandExchange import me.ahoo.wow.command.createBloomFilterIdempotencyChecker import me.ahoo.wow.command.validation.NoOpValidator import me.ahoo.wow.command.wait.LocalCommandWaitNotifier import me.ahoo.wow.command.wait.ProcessedNotifierFilter import me.ahoo.wow.command.wait.SimpleCommandWaitEndpoint import me.ahoo.wow.command.wait.SimpleWaitStrategyRegistrar +import me.ahoo.wow.command.wait.stage.WaitingForStage import me.ahoo.wow.event.DomainEventBus +import me.ahoo.wow.event.DomainEventStream import me.ahoo.wow.event.InMemoryDomainEventBus import me.ahoo.wow.eventsourcing.EventSourcingStateAggregateRepository -import me.ahoo.wow.eventsourcing.InMemoryEventStore +import me.ahoo.wow.eventsourcing.NoopEventStore import me.ahoo.wow.eventsourcing.snapshot.InMemorySnapshotRepository import me.ahoo.wow.eventsourcing.state.InMemoryStateEventBus import me.ahoo.wow.eventsourcing.state.SendStateEventFilter @@ -36,11 +42,17 @@ import me.ahoo.wow.infra.idempotency.DefaultAggregateIdempotencyCheckerProvider import me.ahoo.wow.ioc.SimpleServiceProvider import me.ahoo.wow.modeling.command.RetryableAggregateProcessorFactory import me.ahoo.wow.modeling.command.SimpleCommandAggregateFactory +import me.ahoo.wow.modeling.command.AggregateProcessor +import me.ahoo.wow.modeling.command.AggregateProcessorFactory +import me.ahoo.wow.modeling.command.CommandAggregateFactory import me.ahoo.wow.modeling.command.dispatcher.AggregateProcessorFilter import me.ahoo.wow.modeling.command.dispatcher.CommandDispatcher +import me.ahoo.wow.modeling.command.dispatcher.CommandHandler import me.ahoo.wow.modeling.command.dispatcher.DefaultCommandHandler import me.ahoo.wow.modeling.command.dispatcher.SendDomainEventStreamFilter +import me.ahoo.wow.modeling.metadata.AggregateMetadata import me.ahoo.wow.modeling.state.ConstructorStateAggregateFactory +import me.ahoo.wow.modeling.state.StateAggregateFactory import me.ahoo.wow.modeling.state.StateAggregateRepository import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope @@ -48,11 +60,17 @@ import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State import org.openjdk.jmh.annotations.TearDown import org.openjdk.jmh.infra.Blackhole +import reactor.core.publisher.Mono @State(Scope.Benchmark) open class CommandProcessingPipelineBenchmark { private lateinit var commandGateway: CommandGateway private lateinit var commandDispatcher: CommandDispatcher + private lateinit var aggregateOnlyHandler: CommandHandler + private lateinit var aggregateOnlyWithoutRetryHandler: CommandHandler + private lateinit var aggregateAndDomainEventHandler: CommandHandler + private lateinit var aggregateDomainAndStateEventHandler: CommandHandler + private lateinit var aggregateDomainStateAndProcessedNotifierHandler: CommandHandler @Setup fun setup() { @@ -60,7 +78,7 @@ open class CommandProcessingPipelineBenchmark { val commandBus = InMemoryCommandBus() val domainEventBus: DomainEventBus = InMemoryDomainEventBus() val stateEventBus = InMemoryStateEventBus() - val eventStore = InMemoryEventStore() + val eventStore = NoopEventStore val snapshotRepository = InMemorySnapshotRepository() commandGateway = DefaultCommandGateway( @@ -85,6 +103,42 @@ open class CommandProcessingPipelineBenchmark { stateAggregateRepository, SimpleCommandAggregateFactory(eventStore), ) + val directAggregateProcessorFactory = DirectAggregateProcessorFactory( + ConstructorStateAggregateFactory, + stateAggregateRepository, + SimpleCommandAggregateFactory(eventStore), + ) + aggregateOnlyHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(AggregateProcessorFilter(SimpleServiceProvider(), aggregateProcessorFactory)) + .build() + ) + aggregateOnlyWithoutRetryHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(AggregateProcessorFilter(SimpleServiceProvider(), directAggregateProcessorFactory)) + .build() + ) + aggregateAndDomainEventHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(AggregateProcessorFilter(SimpleServiceProvider(), aggregateProcessorFactory)) + .addFilter(SendDomainEventStreamFilter(domainEventBus)) + .build() + ) + aggregateDomainAndStateEventHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(AggregateProcessorFilter(SimpleServiceProvider(), aggregateProcessorFactory)) + .addFilter(SendDomainEventStreamFilter(domainEventBus)) + .addFilter(SendStateEventFilter(stateEventBus)) + .build() + ) + aggregateDomainStateAndProcessedNotifierHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(AggregateProcessorFilter(SimpleServiceProvider(), aggregateProcessorFactory)) + .addFilter(SendDomainEventStreamFilter(domainEventBus)) + .addFilter(SendStateEventFilter(stateEventBus)) + .addFilter(ProcessedNotifierFilter(commandWaitNotifier)) + .build() + ) val chain = FilterChainBuilder>() .addFilter(AggregateProcessorFilter(SimpleServiceProvider(), aggregateProcessorFactory)) @@ -96,13 +150,21 @@ open class CommandProcessingPipelineBenchmark { namedAggregates = setOf(HotPathFixture.namedAggregate), commandBus = commandGateway, commandHandler = DefaultCommandHandler(chain), + schedulerSupplier = BenchmarkAggregateSchedulerSupplier(), ) commandDispatcher.start() } + private fun createServerExchange(): ServerCommandExchange<*> { + val exchange = SimpleServerCommandExchange(HotPathFixture.createCommandMessage()) + exchange.setAggregateMetadata(HotPathFixture.aggregateMetadata) + return exchange + } + @TearDown fun tearDown() { commandDispatcher.stop() + commandGateway.close() } @Benchmark @@ -117,6 +179,78 @@ open class CommandProcessingPipelineBenchmark { } } + @Benchmark + fun handleAggregateOnly(blackhole: Blackhole) { + try { + val result = aggregateOnlyHandler.handle(createServerExchange()).block() + blackhole.consume(result) + } catch (e: WowException) { + blackhole.consume(e) + } + } + + @Benchmark + fun handleAggregateOnlyWithoutRetry(blackhole: Blackhole) { + try { + val result = aggregateOnlyWithoutRetryHandler.handle(createServerExchange()).block() + blackhole.consume(result) + } catch (e: WowException) { + blackhole.consume(e) + } + } + + @Benchmark + fun handleAggregateAndDomainEvent(blackhole: Blackhole) { + try { + val result = aggregateAndDomainEventHandler.handle(createServerExchange()).block() + blackhole.consume(result) + } catch (e: WowException) { + blackhole.consume(e) + } + } + + @Benchmark + fun handleAggregateDomainAndStateEvent(blackhole: Blackhole) { + try { + val result = aggregateDomainAndStateEventHandler.handle(createServerExchange()).block() + blackhole.consume(result) + } catch (e: WowException) { + blackhole.consume(e) + } + } + + @Benchmark + fun handleAggregateDomainStateAndProcessedNotifierWithoutWait(blackhole: Blackhole) { + try { + val result = aggregateDomainStateAndProcessedNotifierHandler.handle(createServerExchange()).block() + blackhole.consume(result) + } catch (e: WowException) { + blackhole.consume(e) + } + } + + @Benchmark + fun handleAggregateDomainStateAndProcessedNotifierWithLocalWait(blackhole: Blackhole) { + try { + val commandMessage = HotPathFixture.createCommandMessage() + val waitStrategy = WaitingForStage.processed(commandMessage.commandId) + waitStrategy.propagate("", commandMessage.header) + SimpleWaitStrategyRegistrar.register(waitStrategy) + waitStrategy.onFinally { + SimpleWaitStrategyRegistrar.unregister(waitStrategy.waitCommandId) + } + val exchange = SimpleServerCommandExchange(commandMessage) + exchange.setAggregateMetadata(HotPathFixture.aggregateMetadata) + val result = aggregateDomainStateAndProcessedNotifierHandler + .handle(exchange) + .then(waitStrategy.waitingLast()) + .block() + blackhole.consume(result) + } catch (e: WowException) { + blackhole.consume(e) + } + } + @Benchmark fun sendFireAndForget(blackhole: Blackhole) { try { @@ -129,3 +263,47 @@ open class CommandProcessingPipelineBenchmark { } } } + +private class DirectAggregateProcessorFactory( + private val stateAggregateFactory: StateAggregateFactory, + private val stateAggregateRepository: StateAggregateRepository, + private val commandAggregateFactory: CommandAggregateFactory +) : AggregateProcessorFactory { + override fun create( + aggregateId: AggregateId, + aggregateMetadata: AggregateMetadata + ): AggregateProcessor = + DirectAggregateProcessor( + aggregateId = aggregateId, + aggregateMetadata = aggregateMetadata, + stateAggregateFactory = stateAggregateFactory, + stateAggregateRepository = stateAggregateRepository, + commandAggregateFactory = commandAggregateFactory, + ) +} + +private class DirectAggregateProcessor( + override val aggregateId: AggregateId, + private val aggregateMetadata: AggregateMetadata, + private val stateAggregateFactory: StateAggregateFactory, + private val stateAggregateRepository: StateAggregateRepository, + private val commandAggregateFactory: CommandAggregateFactory +) : AggregateProcessor, NamedTypedAggregate by aggregateMetadata.command { + override val processorName: String = DirectAggregateProcessor::class.simpleName!! + + override fun process(exchange: ServerCommandExchange<*>): Mono { + val stateAggregateMono = if (exchange.message.isCreate) { + stateAggregateFactory.createAsMono(aggregateMetadata.state, exchange.message.aggregateId) + } else { + stateAggregateRepository.load(aggregateId, aggregateMetadata.state) + } + return stateAggregateMono + .map { + commandAggregateFactory.create(aggregateMetadata, it) + } + .flatMap { + exchange.clearError() + it.process(exchange) + } + } +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/SnapshotSaveBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/SnapshotSaveBenchmark.kt index 2df7c7a36cd..c9934ec5357 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/SnapshotSaveBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/hotpath/SnapshotSaveBenchmark.kt @@ -15,6 +15,7 @@ package me.ahoo.wow.hotpath import me.ahoo.wow.eventsourcing.snapshot.InMemorySnapshotRepository import me.ahoo.wow.eventsourcing.snapshot.SimpleSnapshot +import me.ahoo.wow.eventsourcing.state.StateEvent.Companion.toStateEvent import me.ahoo.wow.modeling.state.ConstructorStateAggregateFactory import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope @@ -34,7 +35,7 @@ open class SnapshotSaveBenchmark { HotPathFixture.aggregateMetadata.state, HotPathFixture.aggregateId, ) - snapshot = SimpleSnapshot(aggregate) + snapshot = SimpleSnapshot(HotPathFixture.createEventStream().toStateEvent(aggregate)) } @Benchmark diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/DeepCopyBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/DeepCopyBenchmark.kt index 8c1c409ce89..099d5728936 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/DeepCopyBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/DeepCopyBenchmark.kt @@ -15,14 +15,12 @@ package me.ahoo.wow.infra import me.ahoo.wow.event.DomainEventStream import me.ahoo.wow.eventsourcing.createEventStream -import me.ahoo.wow.serialization.deepCopy -import me.ahoo.wow.serialization.toJsonNode import me.ahoo.wow.serialization.toLinkedHashMap +import me.ahoo.wow.serialization.toJsonString import me.ahoo.wow.serialization.toObject import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.State -import tools.jackson.databind.node.ObjectNode @State(Scope.Benchmark) open class DeepCopyBenchmark { @@ -30,13 +28,13 @@ open class DeepCopyBenchmark { private val eventStream: DomainEventStream = createEventStream() @Benchmark - fun toJsonNodeToObject(): DomainEventStream { - return eventStream.toJsonNode().toObject() + fun jsonRoundTrip(): DomainEventStream { + return eventStream.toJsonString().toObject() } @Benchmark - fun convertValue(): DomainEventStream { - return eventStream.deepCopy(DomainEventStream::class.java) + fun domainCopy(): DomainEventStream { + return eventStream.copy() } @Benchmark @@ -44,4 +42,4 @@ open class DeepCopyBenchmark { return eventStream.toLinkedHashMap() } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/SinkBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/SinkBenchmark.kt index 09f3119770e..66dd30da369 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/SinkBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/infra/SinkBenchmark.kt @@ -19,6 +19,8 @@ import org.openjdk.jmh.annotations.Benchmark 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 reactor.core.Disposable import reactor.core.publisher.Mono import reactor.core.publisher.Sinks import reactor.core.scheduler.Scheduler @@ -29,13 +31,25 @@ open class SinkBenchmark { private lateinit var sink: Sinks.Many private lateinit var concurrentManySink: ConcurrentManySink private lateinit var emitScheduler: Scheduler + private lateinit var sinkSubscription: Disposable + private lateinit var concurrentManySinkSubscription: Disposable @Setup fun setup() { sink = Sinks.many().unicast().onBackpressureBuffer() - sink.asFlux().subscribe() + sinkSubscription = sink.asFlux().subscribe() emitScheduler = Schedulers.newSingle("emit-scheduler") concurrentManySink = Sinks.unsafe().many().unicast().onBackpressureBuffer().concurrent() + concurrentManySinkSubscription = concurrentManySink.asFlux().subscribe() + } + + @TearDown + fun tearDown() { + emitScheduler.dispose() + sink.tryEmitComplete() + concurrentManySink.tryEmitComplete() + sinkSubscription.dispose() + concurrentManySinkSubscription.dispose() } @Benchmark @@ -69,4 +83,4 @@ open class SinkBenchmark { }.subscribeOn(emitScheduler).block() } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/AbstractCommandDispatcherBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/AbstractCommandDispatcherBenchmark.kt index 907f09b2c6a..aa3074f0250 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/AbstractCommandDispatcherBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/AbstractCommandDispatcherBenchmark.kt @@ -13,6 +13,8 @@ package me.ahoo.wow.modeling +import me.ahoo.wow.api.command.CommandMessage +import me.ahoo.wow.BenchmarkAggregateSchedulerSupplier import me.ahoo.wow.command.CommandBus import me.ahoo.wow.command.CommandGateway import me.ahoo.wow.command.DefaultCommandGateway @@ -20,6 +22,7 @@ import me.ahoo.wow.command.InMemoryCommandBus import me.ahoo.wow.command.ServerCommandExchange import me.ahoo.wow.command.createBloomFilterIdempotencyChecker import me.ahoo.wow.command.createCommandMessage +import me.ahoo.wow.example.api.cart.AddCartItem import me.ahoo.wow.command.wait.CommandWaitNotifier import me.ahoo.wow.command.wait.LocalCommandWaitNotifier import me.ahoo.wow.command.wait.ProcessedNotifierFilter @@ -49,6 +52,7 @@ import me.ahoo.wow.modeling.command.dispatcher.DefaultCommandHandler import me.ahoo.wow.modeling.command.dispatcher.SendDomainEventStreamFilter import me.ahoo.wow.modeling.state.ConstructorStateAggregateFactory import me.ahoo.wow.modeling.state.StateAggregateRepository +import me.ahoo.wow.scheduler.AggregateSchedulerSupplier import me.ahoo.wow.test.validation.TestValidator import org.openjdk.jmh.infra.Blackhole @@ -94,7 +98,8 @@ abstract class AbstractCommandDispatcherBenchmark { .build() commandDispatcher = CommandDispatcher( commandBus = commandGateway, - commandHandler = DefaultCommandHandler(chain) + commandHandler = DefaultCommandHandler(chain), + schedulerSupplier = createSchedulerSupplier() ) commandDispatcher.start() } @@ -125,8 +130,17 @@ abstract class AbstractCommandDispatcherBenchmark { return InMemorySnapshotRepository() } + open fun createSchedulerSupplier(): AggregateSchedulerSupplier { + return BenchmarkAggregateSchedulerSupplier() + } + + open fun createBenchmarkCommandMessage(): CommandMessage { + return createCommandMessage() + } + open fun destroy() { commandDispatcher.stop() + commandGateway.close() } inline fun run(blackHole: Blackhole, block: () -> Any?) { @@ -138,22 +152,24 @@ abstract class AbstractCommandDispatcherBenchmark { } } + // Sent-only helpers are intentionally not JMH benchmarks: tight loops can + // outpace dispatcher processing and measure backlog pressure instead. open fun send(blackHole: Blackhole) { run(blackHole) { - commandGateway.send(createCommandMessage()).block() + commandGateway.send(createBenchmarkCommandMessage()).block() } } open fun sendAndWaitForSent(blackHole: Blackhole) { run(blackHole) { - commandGateway.sendAndWaitForSent(createCommandMessage()).block() + commandGateway.sendAndWaitForSent(createBenchmarkCommandMessage()).block() } } open fun sendAndWaitForProcessed(blackHole: Blackhole) { run(blackHole) { - commandGateway.sendAndWaitForProcessed(createCommandMessage()).block() + commandGateway.sendAndWaitForProcessed(createBenchmarkCommandMessage()).block() } } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherBenchmark.kt index 8da35afd53f..ba029b1c7f6 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherBenchmark.kt @@ -13,7 +13,11 @@ package me.ahoo.wow.modeling +import me.ahoo.wow.api.command.CommandMessage +import me.ahoo.wow.command.createCommandMessageForNewAggregate +import me.ahoo.wow.example.api.cart.AddCartItem import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Level import org.openjdk.jmh.annotations.Scope import org.openjdk.jmh.annotations.Setup import org.openjdk.jmh.annotations.State @@ -23,30 +27,22 @@ import org.openjdk.jmh.infra.Blackhole @State(Scope.Benchmark) open class InMemoryCommandDispatcherBenchmark : AbstractCommandDispatcherBenchmark() { - @Setup + @Setup(Level.Iteration) override fun setup() { super.setup() } - @TearDown + @TearDown(Level.Iteration) override fun destroy() { super.destroy() } - @Benchmark - override fun send(blackHole: Blackhole) { - super.send(blackHole) - } - - @Benchmark - override fun sendAndWaitForSent(blackHole: Blackhole) { - super.sendAndWaitForSent(blackHole) - + override fun createBenchmarkCommandMessage(): CommandMessage { + return createCommandMessageForNewAggregate() } @Benchmark - override fun sendAndWaitForProcessed(blackHole: Blackhole) { + fun sendAndWaitForProcessedForNewAggregate(blackHole: Blackhole) { super.sendAndWaitForProcessed(blackHole) - } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherGrowthBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherGrowthBenchmark.kt new file mode 100644 index 00000000000..fcd6fe1dac5 --- /dev/null +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/InMemoryCommandDispatcherGrowthBenchmark.kt @@ -0,0 +1,41 @@ +/* + * Copyright [2021-present] [ahoo wang (https://github.com/Ahoo-Wang)]. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ahoo.wow.modeling + +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Level +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 org.openjdk.jmh.infra.Blackhole + +@State(Scope.Benchmark) +open class InMemoryCommandDispatcherGrowthBenchmark : AbstractCommandDispatcherBenchmark() { + + @Setup(Level.Iteration) + override fun setup() { + super.setup() + } + + @TearDown(Level.Iteration) + override fun destroy() { + super.destroy() + } + + @Benchmark + fun sendAndWaitForProcessedWithGrowingStream(blackHole: Blackhole) { + super.sendAndWaitForProcessed(blackHole) + } +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopCommandDispatcherBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopCommandDispatcherBenchmark.kt index 5127f8ed6d1..7fab1fac5f2 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopCommandDispatcherBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopCommandDispatcherBenchmark.kt @@ -48,20 +48,8 @@ open class NoopCommandDispatcherBenchmark : AbstractCommandDispatcherBenchmark() super.destroy() } - @Benchmark - override fun send(blackHole: Blackhole) { - super.send(blackHole) - } - - @Benchmark - override fun sendAndWaitForSent(blackHole: Blackhole) { - super.sendAndWaitForSent(blackHole) - - } - @Benchmark override fun sendAndWaitForProcessed(blackHole: Blackhole) { super.sendAndWaitForProcessed(blackHole) - } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopEventStoreCommandDispatcherBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopEventStoreCommandDispatcherBenchmark.kt index f9b418bd10c..ec67d725b56 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopEventStoreCommandDispatcherBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/modeling/NoopEventStoreCommandDispatcherBenchmark.kt @@ -39,18 +39,8 @@ open class NoopEventStoreCommandDispatcherBenchmark : AbstractCommandDispatcherB super.destroy() } - @Benchmark - override fun send(blackHole: Blackhole) { - super.send(blackHole) - } - - @Benchmark - override fun sendAndWaitForSent(blackHole: Blackhole) { - super.sendAndWaitForSent(blackHole) - } - @Benchmark override fun sendAndWaitForProcessed(blackHole: Blackhole) { super.sendAndWaitForProcessed(blackHole) } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/mongo/MongoCommandDispatcherBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/mongo/MongoCommandDispatcherBenchmark.kt index 4794668549f..7db5a828652 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/mongo/MongoCommandDispatcherBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/mongo/MongoCommandDispatcherBenchmark.kt @@ -13,7 +13,10 @@ package me.ahoo.wow.mongo +import me.ahoo.wow.api.command.CommandMessage +import me.ahoo.wow.command.createCommandMessageForNewAggregate import me.ahoo.wow.eventsourcing.EventStore +import me.ahoo.wow.example.api.cart.AddCartItem import me.ahoo.wow.modeling.AbstractCommandDispatcherBenchmark import org.openjdk.jmh.annotations.Benchmark import org.openjdk.jmh.annotations.Scope @@ -42,18 +45,12 @@ open class MongoCommandDispatcherBenchmark : AbstractCommandDispatcherBenchmark( return MongoEventStore(mongoInitializer.database) } - @Benchmark - override fun send(blackHole: Blackhole) { - super.send(blackHole) - } - - @Benchmark - override fun sendAndWaitForSent(blackHole: Blackhole) { - super.sendAndWaitForSent(blackHole) + override fun createBenchmarkCommandMessage(): CommandMessage { + return createCommandMessageForNewAggregate() } @Benchmark - override fun sendAndWaitForProcessed(blackHole: Blackhole) { + fun sendAndWaitForProcessedForNewAggregate(blackHole: Blackhole) { super.sendAndWaitForProcessed(blackHole) } -} \ No newline at end of file +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisBenchmarkFixture.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisBenchmarkFixture.kt index 4c751ae13de..69105905ad3 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisBenchmarkFixture.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisBenchmarkFixture.kt @@ -14,11 +14,17 @@ package me.ahoo.wow.redis import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.ReactiveRedisConnection import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.ReactiveStringRedisTemplate +import java.time.Duration class RedisBenchmarkFixture : AutoCloseable { + companion object { + private val FLUSH_TIMEOUT: Duration = Duration.ofSeconds(30) + } + val connectionFactory: LettuceConnectionFactory val redisTemplate: ReactiveStringRedisTemplate @@ -30,9 +36,22 @@ class RedisBenchmarkFixture : AutoCloseable { connectionFactory = LettuceConnectionFactory(redisConfig, lettuceClientConfiguration) connectionFactory.afterPropertiesSet() redisTemplate = ReactiveStringRedisTemplate(connectionFactory) + flushDb() } override fun close() { + runCatching { + flushDb() + } connectionFactory.destroy() } + + private fun flushDb() { + val connection: ReactiveRedisConnection = connectionFactory.reactiveConnection + try { + connection.serverCommands().flushDb().block(FLUSH_TIMEOUT) + } finally { + connection.close() + } + } } diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandDispatcherBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandDispatcherBenchmark.kt index 477c7f55813..b552b62543e 100644 --- a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandDispatcherBenchmark.kt +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandDispatcherBenchmark.kt @@ -13,7 +13,10 @@ package me.ahoo.wow.redis +import me.ahoo.wow.api.command.CommandMessage +import me.ahoo.wow.command.createCommandMessageForNewAggregate import me.ahoo.wow.eventsourcing.EventStore +import me.ahoo.wow.example.api.cart.AddCartItem import me.ahoo.wow.modeling.AbstractCommandDispatcherBenchmark import me.ahoo.wow.redis.eventsourcing.RedisEventStore import org.openjdk.jmh.annotations.Benchmark @@ -43,18 +46,12 @@ open class RedisCommandDispatcherBenchmark : AbstractCommandDispatcherBenchmark( return RedisEventStore(redis.redisTemplate) } - @Benchmark - override fun send(blackHole: Blackhole) { - super.send(blackHole) - } - - @Benchmark - override fun sendAndWaitForSent(blackHole: Blackhole) { - super.sendAndWaitForSent(blackHole) + override fun createBenchmarkCommandMessage(): CommandMessage { + return createCommandMessageForNewAggregate() } @Benchmark - override fun sendAndWaitForProcessed(blackHole: Blackhole) { + fun sendAndWaitForProcessedForNewAggregate(blackHole: Blackhole) { super.sendAndWaitForProcessed(blackHole) } } diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandProcessingPipelineBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandProcessingPipelineBenchmark.kt new file mode 100644 index 00000000000..6946d54d896 --- /dev/null +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisCommandProcessingPipelineBenchmark.kt @@ -0,0 +1,165 @@ +/* + * Copyright [2021-present] [ahoo wang (https://github.com/Ahoo-Wang)]. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ahoo.wow.redis + +import me.ahoo.wow.command.ServerCommandExchange +import me.ahoo.wow.command.SimpleServerCommandExchange +import me.ahoo.wow.command.cartAggregateMetadata +import me.ahoo.wow.command.createCommandMessageForNewAggregate +import me.ahoo.wow.command.wait.LocalCommandWaitNotifier +import me.ahoo.wow.command.wait.ProcessedNotifierFilter +import me.ahoo.wow.command.wait.SimpleWaitStrategyRegistrar +import me.ahoo.wow.command.wait.stage.WaitingForStage +import me.ahoo.wow.event.InMemoryDomainEventBus +import me.ahoo.wow.eventsourcing.EventSourcingStateAggregateRepository +import me.ahoo.wow.eventsourcing.EventStore +import me.ahoo.wow.eventsourcing.snapshot.InMemorySnapshotRepository +import me.ahoo.wow.eventsourcing.state.InMemoryStateEventBus +import me.ahoo.wow.eventsourcing.state.SendStateEventFilter +import me.ahoo.wow.exception.WowException +import me.ahoo.wow.filter.FilterChainBuilder +import me.ahoo.wow.ioc.SimpleServiceProvider +import me.ahoo.wow.modeling.command.RetryableAggregateProcessorFactory +import me.ahoo.wow.modeling.command.SimpleCommandAggregateFactory +import me.ahoo.wow.modeling.command.dispatcher.AggregateProcessorFilter +import me.ahoo.wow.modeling.command.dispatcher.CommandHandler +import me.ahoo.wow.modeling.command.dispatcher.DefaultCommandHandler +import me.ahoo.wow.modeling.command.dispatcher.SendDomainEventStreamFilter +import me.ahoo.wow.modeling.state.ConstructorStateAggregateFactory +import me.ahoo.wow.redis.eventsourcing.RedisEventStore +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +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 org.openjdk.jmh.annotations.Threads +import org.openjdk.jmh.annotations.Warmup +import org.openjdk.jmh.infra.Blackhole + +@Warmup(iterations = 1) +@Measurement(iterations = 2) +@Fork(value = 2) +@Threads(5) +@State(Scope.Benchmark) +open class RedisCommandProcessingPipelineBenchmark { + private lateinit var redis: RedisBenchmarkFixture + private lateinit var aggregateOnlyHandler: CommandHandler + private lateinit var aggregateDomainAndStateEventHandler: CommandHandler + private lateinit var aggregateDomainStateAndProcessedNotifierHandler: CommandHandler + + @Setup + fun setup() { + redis = RedisBenchmarkFixture() + val eventStore = RedisEventStore(redis.redisTemplate) + val commandWaitNotifier = LocalCommandWaitNotifier(SimpleWaitStrategyRegistrar) + val aggregateProcessorFilter = AggregateProcessorFilter( + serviceProvider = SimpleServiceProvider(), + aggregateProcessorFactory = createAggregateProcessorFactory(eventStore), + ) + aggregateOnlyHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(aggregateProcessorFilter) + .build() + ) + aggregateDomainAndStateEventHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(aggregateProcessorFilter) + .addFilter(SendDomainEventStreamFilter(InMemoryDomainEventBus())) + .addFilter(SendStateEventFilter(InMemoryStateEventBus())) + .build() + ) + aggregateDomainStateAndProcessedNotifierHandler = DefaultCommandHandler( + FilterChainBuilder>() + .addFilter(aggregateProcessorFilter) + .addFilter(SendDomainEventStreamFilter(InMemoryDomainEventBus())) + .addFilter(SendStateEventFilter(InMemoryStateEventBus())) + .addFilter(ProcessedNotifierFilter(commandWaitNotifier)) + .build() + ) + } + + @TearDown + fun tearDown() { + redis.close() + } + + private fun createAggregateProcessorFactory(eventStore: EventStore): RetryableAggregateProcessorFactory { + val stateAggregateRepository = EventSourcingStateAggregateRepository( + ConstructorStateAggregateFactory, + InMemorySnapshotRepository(), + eventStore, + ) + return RetryableAggregateProcessorFactory( + ConstructorStateAggregateFactory, + stateAggregateRepository, + SimpleCommandAggregateFactory(eventStore), + ) + } + + private fun createServerExchange(): ServerCommandExchange<*> { + val exchange = SimpleServerCommandExchange(createCommandMessageForNewAggregate()) + exchange.setAggregateMetadata(cartAggregateMetadata) + return exchange + } + + private fun run(blackHole: Blackhole, block: () -> Any?) { + try { + blackHole.consume(block()) + } catch (wowException: WowException) { + blackHole.consume(wowException) + } + } + + @Benchmark + fun handleAggregateOnly(blackHole: Blackhole) { + run(blackHole) { + aggregateOnlyHandler.handle(createServerExchange()).block() + } + } + + @Benchmark + fun handleAggregateDomainAndStateEvent(blackHole: Blackhole) { + run(blackHole) { + aggregateDomainAndStateEventHandler.handle(createServerExchange()).block() + } + } + + @Benchmark + fun handleAggregateDomainStateAndProcessedNotifierWithoutWait(blackHole: Blackhole) { + run(blackHole) { + aggregateDomainStateAndProcessedNotifierHandler.handle(createServerExchange()).block() + } + } + + @Benchmark + fun handleAggregateDomainStateAndProcessedNotifierWithLocalWait(blackHole: Blackhole) { + run(blackHole) { + val commandMessage = createCommandMessageForNewAggregate() + val waitStrategy = WaitingForStage.processed(commandMessage.commandId) + waitStrategy.propagate("", commandMessage.header) + SimpleWaitStrategyRegistrar.register(waitStrategy) + waitStrategy.onFinally { + SimpleWaitStrategyRegistrar.unregister(waitStrategy.waitCommandId) + } + val exchange = SimpleServerCommandExchange(commandMessage) + exchange.setAggregateMetadata(cartAggregateMetadata) + aggregateDomainStateAndProcessedNotifierHandler + .handle(exchange) + .then(waitStrategy.waitingLast()) + .block() + } + } +} diff --git a/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisEventStoreReadBenchmark.kt b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisEventStoreReadBenchmark.kt new file mode 100644 index 00000000000..9aac5d32c23 --- /dev/null +++ b/wow-benchmarks/src/jmh/kotlin/me/ahoo/wow/redis/RedisEventStoreReadBenchmark.kt @@ -0,0 +1,93 @@ +/* + * Copyright [2021-present] [ahoo wang (https://github.com/Ahoo-Wang)]. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ahoo.wow.redis + +import me.ahoo.wow.api.modeling.AggregateId +import me.ahoo.wow.command.cartAggregateMetadata +import me.ahoo.wow.event.DomainEventStream +import me.ahoo.wow.event.toDomainEventStream +import me.ahoo.wow.example.api.cart.CartItem +import me.ahoo.wow.example.api.cart.CartItemAdded +import me.ahoo.wow.eventsourcing.EventStore +import me.ahoo.wow.modeling.aggregateId +import me.ahoo.wow.redis.eventsourcing.RedisEventStore +import me.ahoo.wow.test.aggregate.GivenInitializationCommand +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +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 org.openjdk.jmh.annotations.Threads +import org.openjdk.jmh.annotations.Warmup +import org.openjdk.jmh.infra.Blackhole + +@Warmup(iterations = 1) +@Measurement(iterations = 2) +@Fork(value = 2) +@Threads(5) +@State(Scope.Benchmark) +open class RedisEventStoreReadBenchmark { + @Param("10", "100") + var eventCount: Int = 10 + + private lateinit var redis: RedisBenchmarkFixture + private lateinit var eventStore: EventStore + private lateinit var aggregateId: AggregateId + + @Setup + fun setup() { + redis = RedisBenchmarkFixture() + eventStore = RedisEventStore(redis.redisTemplate) + aggregateId = cartAggregateMetadata.aggregateId() + for (eventStream in createEventStreams()) { + eventStore.append(eventStream).block() + } + } + + @TearDown + fun tearDown() { + redis.close() + } + + private fun createEventStreams(): List { + return (1..eventCount).map { version -> + val event = CartItemAdded(CartItem("product-$version", version)) + listOf(event).toDomainEventStream( + upstream = GivenInitializationCommand(aggregateId), + aggregateVersion = version - 1, + ) + } + } + + @Benchmark + fun loadAll(blackHole: Blackhole) { + val eventStreams = eventStore.load(aggregateId, 1, eventCount).collectList().block() + blackHole.consume(eventStreams) + } + + @Benchmark + fun single(blackHole: Blackhole) { + val eventStream = eventStore.single(aggregateId, eventCount).block() + blackHole.consume(eventStream) + } + + @Benchmark + fun last(blackHole: Blackhole) { + val eventStream = eventStore.last(aggregateId).block() + blackHole.consume(eventStream) + } +} diff --git a/wow-benchmarks/src/jmh/resources/META-INF/services/me.ahoo.wow.id.GlobalIdGeneratorFactory b/wow-benchmarks/src/jmh/resources/META-INF/services/me.ahoo.wow.id.GlobalIdGeneratorFactory new file mode 100644 index 00000000000..a06d5312d48 --- /dev/null +++ b/wow-benchmarks/src/jmh/resources/META-INF/services/me.ahoo.wow.id.GlobalIdGeneratorFactory @@ -0,0 +1 @@ +me.ahoo.wow.BenchmarkGlobalIdGeneratorFactory diff --git a/wow-core/src/main/java/me/ahoo/wow/infra/accessor/method/FastInvoke.java b/wow-core/src/main/java/me/ahoo/wow/infra/accessor/method/FastInvoke.java index 2153b62a021..20b41ba8791 100644 --- a/wow-core/src/main/java/me/ahoo/wow/infra/accessor/method/FastInvoke.java +++ b/wow-core/src/main/java/me/ahoo/wow/infra/accessor/method/FastInvoke.java @@ -36,6 +36,8 @@ * @see Detekt SpreadOperator Rule */ public final class FastInvoke { + private static final ThreadLocal SINGLE_ARGUMENTS = ThreadLocal.withInitial(() -> new Object[1]); + private FastInvoke() { } @@ -103,6 +105,55 @@ public static T safeInvoke(@NotNull Method method, Object target, Object[] a } } + /** + * Invokes the specified single-argument method on the target object. + *

+ * This method reuses a per-thread one-element argument array, avoiding per-call Object[] + * allocation in hot paths that invoke simple message handlers. + *

+ * + * @param method the method to invoke; must not be null + * @param target the object on which to invoke the method, or null for static methods + * @param arg the single argument to pass to the method + * @param the return type of the method + * @return the result of the method invocation, or null if the method returns void + * @throws InvocationTargetException if the underlying method throws an exception + * @throws IllegalAccessException if the method is not accessible + */ + public static T invokeSingle(@NotNull Method method, Object target, Object arg) + throws InvocationTargetException, IllegalAccessException { + Object[] args = SINGLE_ARGUMENTS.get(); + args[0] = arg; + try { + return invoke(method, target, args); + } finally { + args[0] = null; + } + } + + /** + * Safely invokes the specified single-argument method on the target object. + *

+ * This method calls {@link #invokeSingle(Method, Object, Object)} but throws the target + * exception directly instead of wrapping it in InvocationTargetException. + *

+ * + * @param method the method to invoke; must not be null + * @param target the object on which to invoke the method, or null for static methods + * @param arg the single argument to pass to the method + * @param the return type of the method + * @return the result of the method invocation, or null if the method returns void + * @throws Throwable if the underlying method throws an exception or if invocation fails + */ + public static T safeInvokeSingle(@NotNull Method method, Object target, Object arg) + throws Throwable { + try { + return invokeSingle(method, target, arg); + } catch (InvocationTargetException targetException) { + throw targetException.getTargetException(); + } + } + /** * Creates a new instance of the class using the specified constructor with the given arguments. *

diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/command/CommandExchange.kt b/wow-core/src/main/kotlin/me/ahoo/wow/command/CommandExchange.kt index a0945260cf9..2de29a1cff1 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/command/CommandExchange.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/command/CommandExchange.kt @@ -16,11 +16,19 @@ import me.ahoo.wow.api.command.CommandMessage import me.ahoo.wow.api.event.DomainEvent import me.ahoo.wow.api.exception.ErrorInfo import me.ahoo.wow.api.exception.ErrorInfo.Companion.isFailed +import me.ahoo.wow.api.messaging.function.FunctionInfo import me.ahoo.wow.command.wait.WaitStrategy import me.ahoo.wow.event.DomainEventException.Companion.toException import me.ahoo.wow.event.DomainEventStream +import me.ahoo.wow.ioc.ServiceProvider +import me.ahoo.wow.messaging.handler.COMMAND_RESULT_KEY +import me.ahoo.wow.messaging.handler.ERROR_KEY +import me.ahoo.wow.messaging.handler.FUNCTION_KEY import me.ahoo.wow.messaging.handler.MessageExchange +import me.ahoo.wow.messaging.handler.SERVICE_PROVIDER_KEY import me.ahoo.wow.modeling.command.AggregateProcessor +import me.ahoo.wow.modeling.command.COMMAND_AGGREGATE_KEY +import me.ahoo.wow.modeling.command.CommandAggregate import me.ahoo.wow.modeling.command.getCommandAggregate import me.ahoo.wow.modeling.metadata.AggregateMetadata import java.util.concurrent.ConcurrentHashMap @@ -70,8 +78,23 @@ interface ClientCommandExchange : CommandExchange( override val message: CommandMessage, override val waitStrategy: WaitStrategy, - override val attributes: MutableMap = ConcurrentHashMap() -) : ClientCommandExchange + attributes: MutableMap? = null +) : ClientCommandExchange { + @Volatile + private var lazyAttributes: MutableMap? = attributes + + override val attributes: MutableMap + get() { + lazyAttributes?.let { + return it + } + return synchronized(this) { + lazyAttributes ?: ConcurrentHashMap().also { + lazyAttributes = it + } + } + } +} const val COMMAND_INVOKE_RESULT_KEY = "__COMMAND_INVOKE_RESULT__" const val EVENT_STREAM_KEY = "__EVENT_STREAM__" @@ -241,5 +264,153 @@ interface ServerCommandExchange : CommandExchange( override val message: CommandMessage, - override val attributes: MutableMap = ConcurrentHashMap() -) : ServerCommandExchange + attributes: MutableMap? = null +) : ServerCommandExchange { + private companion object { + private const val ATTRIBUTE_MAP_INITIAL_CAPACITY = 8 + } + + @Volatile + private var lazyAttributes: MutableMap? = attributes + + @Volatile + private var aggregateMetadata: AggregateMetadata<*, *>? = null + + @Volatile + private var aggregateProcessor: AggregateProcessor<*>? = null + + @Volatile + private var commandAggregate: CommandAggregate<*, *>? = null + + @Volatile + private var commandInvokeResult: Any? = null + + @Volatile + private var eventStream: DomainEventStream? = null + + @Volatile + private var aggregateVersion: Int? = null + + @Volatile + private var error: Throwable? = null + + @Volatile + private var function: FunctionInfo? = null + + @Volatile + private var serviceProvider: ServiceProvider? = null + + @Volatile + private var commandResult: Map? = null + + override val attributes: MutableMap + get() { + lazyAttributes?.let { + return it + } + return synchronized(this) { + lazyAttributes ?: ConcurrentHashMap(ATTRIBUTE_MAP_INITIAL_CAPACITY).also { + copyFieldAttributesTo(it) + lazyAttributes = it + } + } + } + + override fun setAttribute(key: String, value: Any): ServerCommandExchange { + lazyAttributes?.let { + it[key] = value + return this + } + if (setFieldAttribute(key, value)) { + return this + } + attributes[key] = value + return this + } + + override fun getAttribute(key: String): T? { + lazyAttributes?.let { + @Suppress("UNCHECKED_CAST") + return it[key] as T? + } + @Suppress("UNCHECKED_CAST") + return getFieldAttribute(key) as T? + } + + override fun removeAttribute(key: String): ServerCommandExchange { + lazyAttributes?.let { + it.remove(key) + return this + } + if (removeFieldAttribute(key)) { + return this + } + return this + } + + private fun copyFieldAttributesTo(attributes: MutableMap) { + aggregateMetadata?.let { attributes[AGGREGATE_METADATA_KEY] = it } + aggregateProcessor?.let { attributes[AGGREGATE_PROCESSOR_KEY] = it } + commandAggregate?.let { attributes[COMMAND_AGGREGATE_KEY] = it } + commandInvokeResult?.let { attributes[COMMAND_INVOKE_RESULT_KEY] = it } + eventStream?.let { attributes[EVENT_STREAM_KEY] = it } + aggregateVersion?.let { attributes[AGGREGATE_VERSION_KEY] = it } + error?.let { attributes[ERROR_KEY] = it } + function?.let { attributes[FUNCTION_KEY] = it } + serviceProvider?.let { attributes[SERVICE_PROVIDER_KEY] = it } + commandResult?.let { attributes[COMMAND_RESULT_KEY] = it } + } + + private fun setFieldAttribute(key: String, value: Any): Boolean { + when (key) { + AGGREGATE_METADATA_KEY -> aggregateMetadata = value as AggregateMetadata<*, *> + AGGREGATE_PROCESSOR_KEY -> aggregateProcessor = value as AggregateProcessor<*> + COMMAND_AGGREGATE_KEY -> commandAggregate = value as CommandAggregate<*, *> + COMMAND_INVOKE_RESULT_KEY -> commandInvokeResult = value + EVENT_STREAM_KEY -> eventStream = value as DomainEventStream + AGGREGATE_VERSION_KEY -> aggregateVersion = value as Int + ERROR_KEY -> error = value as Throwable + FUNCTION_KEY -> function = value as FunctionInfo + SERVICE_PROVIDER_KEY -> serviceProvider = value as ServiceProvider + COMMAND_RESULT_KEY -> { + @Suppress("UNCHECKED_CAST") + commandResult = value as Map + } + + else -> return false + } + return true + } + + private fun getFieldAttribute(key: String): Any? = + when (key) { + AGGREGATE_METADATA_KEY -> aggregateMetadata + AGGREGATE_PROCESSOR_KEY -> aggregateProcessor + COMMAND_AGGREGATE_KEY -> commandAggregate + COMMAND_INVOKE_RESULT_KEY -> commandInvokeResult + EVENT_STREAM_KEY -> eventStream + AGGREGATE_VERSION_KEY -> aggregateVersion + ERROR_KEY -> error + FUNCTION_KEY -> function + SERVICE_PROVIDER_KEY -> serviceProvider + COMMAND_RESULT_KEY -> commandResult + else -> null + } + + private fun removeFieldAttribute(key: String): Boolean { + when (key) { + AGGREGATE_METADATA_KEY -> aggregateMetadata = null + AGGREGATE_PROCESSOR_KEY -> aggregateProcessor = null + COMMAND_AGGREGATE_KEY -> commandAggregate = null + COMMAND_INVOKE_RESULT_KEY -> commandInvokeResult = null + EVENT_STREAM_KEY -> eventStream = null + AGGREGATE_VERSION_KEY -> aggregateVersion = null + ERROR_KEY -> error = null + FUNCTION_KEY -> function = null + SERVICE_PROVIDER_KEY -> serviceProvider = null + COMMAND_RESULT_KEY -> commandResult = null + else -> return false + } + return true + } +} diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventExchange.kt b/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventExchange.kt index 44df66d0006..3e36a87f148 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventExchange.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventExchange.kt @@ -53,20 +53,35 @@ interface DomainEventExchange : EventExchange, D * * @param T The type of the domain event body * @property message The domain event being processed - * @property attributes Mutable map for storing exchange attributes (default: empty ConcurrentHashMap) + * @property attributes Mutable map for storing exchange attributes (default: lazy empty ConcurrentHashMap) * * @constructor Creates a new SimpleDomainEventExchange with the given message and attributes * * @param message The domain event message - * @param attributes The mutable map of attributes (default: ConcurrentHashMap) + * @param attributes The mutable map of attributes (default: lazy ConcurrentHashMap) * * @see DomainEventExchange * @see DomainEvent */ class SimpleDomainEventExchange( override val message: DomainEvent, - override val attributes: MutableMap = ConcurrentHashMap() -) : DomainEventExchange + attributes: MutableMap? = null +) : DomainEventExchange { + @Volatile + private var lazyAttributes: MutableMap? = attributes + + override val attributes: MutableMap + get() { + lazyAttributes?.let { + return it + } + return synchronized(this) { + lazyAttributes ?: ConcurrentHashMap().also { + lazyAttributes = it + } + } + } +} /** * Exchange interface for domain events with state context. @@ -127,13 +142,13 @@ interface StateDomainEventExchange : DomainEventExchange { * @param T The type of the domain event body * @property state The read-only state aggregate * @property message The domain event being processed - * @property attributes Mutable map for storing exchange attributes (default: empty ConcurrentHashMap) + * @property attributes Mutable map for storing exchange attributes (default: lazy empty ConcurrentHashMap) * * @constructor Creates a new SimpleStateDomainEventExchange with state, message, and attributes * * @param state The read-only state aggregate * @param message The domain event message - * @param attributes The mutable map of attributes (default: ConcurrentHashMap) + * @param attributes The mutable map of attributes (default: lazy ConcurrentHashMap) * * @see StateDomainEventExchange * @see ReadOnlyStateAggregate @@ -142,5 +157,20 @@ interface StateDomainEventExchange : DomainEventExchange { class SimpleStateDomainEventExchange( override val state: ReadOnlyStateAggregate, override val message: DomainEvent, - override val attributes: MutableMap = ConcurrentHashMap() -) : StateDomainEventExchange + attributes: MutableMap? = null +) : StateDomainEventExchange { + @Volatile + private var lazyAttributes: MutableMap? = attributes + + override val attributes: MutableMap + get() { + lazyAttributes?.let { + return it + } + return synchronized(this) { + lazyAttributes ?: ConcurrentHashMap().also { + lazyAttributes = it + } + } + } +} diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventStreamFactory.kt b/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventStreamFactory.kt index 6c9af59bfaa..12c5d3a35eb 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventStreamFactory.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/event/DomainEventStreamFactory.kt @@ -94,16 +94,15 @@ fun Any.toDomainEventStream( val streamSpaceId = upstream.spaceId.ifBlank { stateSpaceId } - val events = - flatEvent().toDomainEvents( - streamVersion = streamVersion, - aggregateId = aggregateId, - command = upstream, - ownerId = streamOwnerId, - spaceId = streamSpaceId, - eventStreamHeader = header, - createTime = createTime, - ) + val events = toDomainEvents( + streamVersion = streamVersion, + aggregateId = aggregateId, + command = upstream, + ownerId = streamOwnerId, + spaceId = streamSpaceId, + eventStreamHeader = header, + createTime = createTime, + ) return SimpleDomainEventStream( id = eventStreamId, @@ -113,6 +112,59 @@ fun Any.toDomainEventStream( ) } +@Suppress("LongParameterList") +private fun Any.toDomainEvents( + streamVersion: Int, + aggregateId: AggregateId, + command: CommandMessage<*>, + ownerId: String, + spaceId: SpaceId, + eventStreamHeader: Header, + createTime: Long +): List> = + when (this) { + is Iterable<*> -> { + toDomainEvents( + streamVersion = streamVersion, + aggregateId = aggregateId, + command = command, + ownerId = ownerId, + spaceId = spaceId, + eventStreamHeader = eventStreamHeader, + createTime = createTime, + ) + } + + is Array<*> -> { + asList().toDomainEvents( + streamVersion = streamVersion, + aggregateId = aggregateId, + command = command, + ownerId = ownerId, + spaceId = spaceId, + eventStreamHeader = eventStreamHeader, + createTime = createTime, + ) + } + + else -> { + listOf( + toDomainEvent( + id = generateGlobalId(), + version = streamVersion, + sequence = DEFAULT_EVENT_SEQUENCE, + isLast = true, + aggregateId = aggregateId, + ownerId = ownerId, + spaceId = spaceId, + commandId = command.commandId, + header = eventStreamHeader.copy(), + createTime = createTime, + ) + ) + } + } + /** * Converts an iterable of event objects to a list of domain events. * @@ -142,20 +194,28 @@ private fun Iterable<*>.toDomainEvents( eventStreamHeader: Header, createTime: Long ): List> { - val eventCount = count() - return mapIndexed { index, event -> + val eventCount = if (this is Collection<*>) { + size + } else { + count() + } + val events = ArrayList>(eventCount) + forEachIndexed { index, event -> val sequence = (index + DEFAULT_EVENT_SEQUENCE) - event!!.toDomainEvent( - id = generateGlobalId(), - version = streamVersion, - sequence = sequence, - isLast = sequence == eventCount, - aggregateId = aggregateId, - ownerId = ownerId, - spaceId = spaceId, - commandId = command.commandId, - header = eventStreamHeader.copy(), - createTime = createTime, + events.add( + event!!.toDomainEvent( + id = generateGlobalId(), + version = streamVersion, + sequence = sequence, + isLast = sequence == eventCount, + aggregateId = aggregateId, + ownerId = ownerId, + spaceId = spaceId, + commandId = command.commandId, + header = eventStreamHeader.copy(), + createTime = createTime, + ) ) - }.toList() + } + return events } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/event/upgrader/EventUpgraderFactory.kt b/wow-core/src/main/kotlin/me/ahoo/wow/event/upgrader/EventUpgraderFactory.kt index 4f79c00db23..25d02a591de 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/event/upgrader/EventUpgraderFactory.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/event/upgrader/EventUpgraderFactory.kt @@ -84,7 +84,7 @@ object EventUpgraderFactory { */ fun get( eventNamedAggregate: EventNamedAggregate - ): List = eventUpgraderFactories[eventNamedAggregate] ?: listOf() + ): List = eventUpgraderFactories[eventNamedAggregate] ?: emptyList() /** * Upgrades a domain event record using registered upgraders. @@ -101,6 +101,9 @@ object EventUpgraderFactory { * @see toMutableDomainEventRecord */ fun upgrade(domainEventRecord: DomainEventRecord): DomainEventRecord { + if (eventUpgraderFactories.isEmpty()) { + return domainEventRecord + } val namedAggregate = domainEventRecord.toAggregateId().namedAggregate.materialize() val eventNamedAggregate = namedAggregate.toEventNamedAggregate(domainEventRecord.name) val eventUpgraders = get(eventNamedAggregate) diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/FunctionAccessor.kt b/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/FunctionAccessor.kt index 33975c6b846..880a7c2f00f 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/FunctionAccessor.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/FunctionAccessor.kt @@ -98,4 +98,14 @@ interface FunctionAccessor : Named { target: T, args: Array = emptyArray() ): R = FastInvoke.safeInvoke(method, target, args) + + /** + * Invokes a single-argument function without allocating a fresh Object[] on every call. + * + * @param target the object on which to invoke the function + * @param arg the argument to pass to the function + * @return the result of the function invocation + * @throws Throwable if the function invocation fails or throws an exception + */ + fun invokeSingle(target: T, arg: Any?): R = FastInvoke.safeInvokeSingle(method, target, arg) } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/reactive/AbstractMonoFunctionAccessor.kt b/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/reactive/AbstractMonoFunctionAccessor.kt index 4c78ca1d67c..8aa2a8ba43f 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/reactive/AbstractMonoFunctionAccessor.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/infra/accessor/function/reactive/AbstractMonoFunctionAccessor.kt @@ -25,4 +25,6 @@ abstract class AbstractMonoFunctionAccessor> (override val functi init { function.ensureAccessible() } + + override fun invokeSingle(target: T, arg: Any?): D = invoke(target, arrayOf(arg)) } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/messaging/DefaultHeader.kt b/wow-core/src/main/kotlin/me/ahoo/wow/messaging/DefaultHeader.kt index 73fb1547ff2..d61c10c5812 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/messaging/DefaultHeader.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/messaging/DefaultHeader.kt @@ -14,6 +14,237 @@ package me.ahoo.wow.messaging import me.ahoo.wow.api.messaging.Header +private const val DEFAULT_HEADER_CAPACITY = 4 + +private fun newHeaderMap(expectedSize: Int = 0): SmallHeaderMap = SmallHeaderMap(expectedSize) + +private class SmallHeaderMap(expectedSize: Int = 0) : AbstractMutableMap() { + private var slots: Array = if (expectedSize == 0) { + EMPTY + } else { + arrayOfNulls(slotSize(capacity(expectedSize))) + } + private var entryCount: Int = 0 + private var shared: Boolean = false + + override val size: Int + get() = entryCount + + override val entries: MutableSet> + get() = EntrySet() + + override fun containsKey(key: String): Boolean = indexOfKey(key) >= 0 + + override fun containsValue(value: String): Boolean { + for (index in 0 until entryCount) { + if (slots[valueIndex(index)] == value) { + return true + } + } + return false + } + + override fun get(key: String): String? { + val index = indexOfKey(key) + if (index < 0) { + return null + } + return slots[valueIndex(index)] + } + + override fun isEmpty(): Boolean = entryCount == 0 + + override fun put( + key: String, + value: String + ): String? { + val index = indexOfKey(key) + if (index >= 0) { + val previous = slots[valueIndex(index)] + ensureMutable(entryCount) + slots[valueIndex(index)] = value + return previous + } + ensureMutable(entryCount + 1) + slots[keyIndex(entryCount)] = key + slots[valueIndex(entryCount)] = value + entryCount++ + return null + } + + override fun remove(key: String): String? { + val index = indexOfKey(key) + if (index < 0) { + return null + } + return removeAt(index) + } + + override fun clear() { + if (entryCount == 0) { + return + } + if (shared) { + slots = EMPTY + entryCount = 0 + shared = false + return + } + slots.fill(null, 0, slotSize(entryCount)) + entryCount = 0 + } + + fun copyMap(): SmallHeaderMap { + val copied = SmallHeaderMap() + if (entryCount > 0) { + shared = true + copied.shared = true + copied.slots = slots + copied.entryCount = entryCount + } + return copied + } + + private fun indexOfKey(key: String): Int { + for (index in 0 until entryCount) { + if (slots[keyIndex(index)] == key) { + return index + } + } + return -1 + } + + private fun ensureMutable(requiredCapacity: Int) { + val requiredSlotSize = slotSize(requiredCapacity) + if (!shared && slots.size >= requiredSlotSize) { + return + } + val newCapacity = when { + slots.isEmpty() -> DEFAULT_HEADER_CAPACITY + slots.size < requiredSlotSize -> capacity(slots.size / ENTRY_WIDTH) * 2 + else -> slots.size / ENTRY_WIDTH + }.coerceAtLeast(requiredCapacity) + slots = slots.copyOf(slotSize(newCapacity)) + shared = false + } + + private fun removeAt(index: Int): String { + val previous = checkNotNull(slots[valueIndex(index)]) + ensureMutable(entryCount) + val lastIndex = entryCount - 1 + if (index < lastIndex) { + slots.copyInto( + slots, + destinationOffset = keyIndex(index), + startIndex = keyIndex(index + 1), + endIndex = slotSize(entryCount), + ) + } + slots[keyIndex(lastIndex)] = null + slots[valueIndex(lastIndex)] = null + entryCount = lastIndex + return previous + } + + private inner class EntrySet : AbstractMutableSet>() { + override val size: Int + get() = entryCount + + override fun add(element: MutableMap.MutableEntry): Boolean { + throw UnsupportedOperationException("Add is not supported on map entries.") + } + + override fun clear() = this@SmallHeaderMap.clear() + + override fun contains(element: MutableMap.MutableEntry): Boolean { + val index = indexOfKey(element.key) + return index >= 0 && slots[valueIndex(index)] == element.value + } + + override fun iterator(): MutableIterator> = EntryIterator() + + override fun remove(element: MutableMap.MutableEntry): Boolean { + val index = indexOfKey(element.key) + if (index < 0 || slots[valueIndex(index)] != element.value) { + return false + } + removeAt(index) + return true + } + } + + private inner class EntryIterator : MutableIterator> { + private var nextIndex: Int = 0 + private var lastIndex: Int = -1 + + override fun hasNext(): Boolean = nextIndex < entryCount + + override fun next(): MutableMap.MutableEntry { + if (!hasNext()) { + throw NoSuchElementException() + } + val entry = Entry(nextIndex) + lastIndex = nextIndex + nextIndex++ + return entry + } + + override fun remove() { + if (lastIndex < 0) { + throw IllegalStateException() + } + removeAt(lastIndex) + if (lastIndex < nextIndex) { + nextIndex-- + } + lastIndex = -1 + } + } + + private inner class Entry( + private val index: Int + ) : MutableMap.MutableEntry { + override val key: String + get() = checkNotNull(slots[keyIndex(index)]) + + override val value: String + get() = checkNotNull(slots[valueIndex(index)]) + + override fun setValue(newValue: String): String { + val previous = value + ensureMutable(entryCount) + slots[valueIndex(index)] = newValue + return previous + } + + override fun equals(other: Any?): Boolean { + if (other !is Map.Entry<*, *>) { + return false + } + return key == other.key && value == other.value + } + + override fun hashCode(): Int = key.hashCode() xor value.hashCode() + } + + companion object { + private const val ENTRY_WIDTH = 2 + private val EMPTY = arrayOfNulls(0) + + private fun capacity(expectedSize: Int): Int = + when { + expectedSize <= 3 -> DEFAULT_HEADER_CAPACITY + else -> expectedSize + } + + private fun keyIndex(entryIndex: Int): Int = entryIndex * ENTRY_WIDTH + + private fun valueIndex(entryIndex: Int): Int = keyIndex(entryIndex) + 1 + + private fun slotSize(entryCapacity: Int): Int = entryCapacity * ENTRY_WIDTH + } +} + /** * Default implementation of the [Header] interface. * @@ -25,7 +256,7 @@ import me.ahoo.wow.api.messaging.Header * @author ahoo wang */ class DefaultHeader( - private val delegate: MutableMap = mutableMapOf(), + private val delegate: MutableMap = newHeaderMap(), @Volatile override var isReadOnly: Boolean = false ) : Header, @@ -59,7 +290,15 @@ class DefaultHeader( * * @return A new mutable copy of this header */ - override fun copy(): Header = empty().with(this) + override fun copy(): Header { + if (isEmpty()) { + return empty() + } + if (delegate is SmallHeaderMap) { + return DefaultHeader(delegate.copyMap()) + } + return DefaultHeader(newHeaderMap(size).also { it.putAll(this) }) + } /** * Executes a write operation if the header is not read-only. @@ -170,5 +409,5 @@ fun Map?.toHeader(): Header { if (this is Header) { return this } - return DefaultHeader(this.toMutableMap()) + return DefaultHeader(newHeaderMap(size).also { it.putAll(this) }) } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/messaging/function/MessageFunctionAccessor.kt b/wow-core/src/main/kotlin/me/ahoo/wow/messaging/function/MessageFunctionAccessor.kt index e7b1527696a..5a8a7e96e3d 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/messaging/function/MessageFunctionAccessor.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/messaging/function/MessageFunctionAccessor.kt @@ -41,7 +41,7 @@ data class SimpleMessageFunctionAccessor

, */ override fun invoke(exchange: M): R { val firstArgument = metadata.extractFirstArgument(exchange) - return metadata.accessor.invoke(processor, arrayOf(firstArgument)) + return metadata.accessor.invokeSingle(processor, firstArgument) } override fun toString(): String = "SimpleMessageFunctionAccessor(metadata=$metadata)" diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandAggregate.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandAggregate.kt index 0cb341f84b0..c271b24fd96 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandAggregate.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandAggregate.kt @@ -75,15 +75,17 @@ enum class CommandState { SOURCED { override fun onStore(eventStore: EventStore, eventStream: DomainEventStream): Mono { return eventStore.append(eventStream) - .checkpoint( - "Append DomainEventStream[${eventStream.id}] CommandId:[${eventStream.commandId}] [CommandState]" - ) + .checkpoint(STORE_EVENT_STREAM_CHECKPOINT) .thenReturn(STORED) } }, EXPIRED ; + private companion object { + const val STORE_EVENT_STREAM_CHECKPOINT = "Append DomainEventStream [CommandState]" + } + /** * Applies event sourcing to the state aggregate with the given event stream. * diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandFunction.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandFunction.kt index 6a4f990cb70..597a0208853 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandFunction.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/CommandFunction.kt @@ -17,6 +17,8 @@ import me.ahoo.wow.api.messaging.function.FunctionKind import me.ahoo.wow.api.modeling.NamedAggregate import me.ahoo.wow.command.ServerCommandExchange import me.ahoo.wow.infra.Decorator +import me.ahoo.wow.infra.reflection.AnnotationScanner.scanAnnotation +import me.ahoo.wow.messaging.function.FunctionAccessorMetadata import me.ahoo.wow.messaging.function.MessageFunction import me.ahoo.wow.modeling.command.after.AfterCommandFunction import me.ahoo.wow.modeling.materialize @@ -38,13 +40,17 @@ import reactor.core.publisher.Mono class CommandFunction( override val delegate: MessageFunction, Mono<*>>, commandAggregate: CommandAggregate, - afterCommandFunctions: List> + afterCommandFunctions: List>, + override val supportedTopics: Set = setOf(commandAggregate.materialize()) ) : AbstractCommandFunction(commandAggregate, afterCommandFunctions), Decorator, Mono<*>>> { + private companion object { + const val INVOKE_COMMAND_CHECKPOINT = "Invoke Command [CommandFunction]" + } + override val contextName: String = delegate.contextName override val name: String = delegate.name override val supportedType: Class<*> = delegate.supportedType - override val supportedTopics: Set = setOf(commandAggregate.materialize()) override val processor: C = delegate.processor override val functionKind: FunctionKind = delegate.functionKind @@ -59,9 +65,36 @@ class CommandFunction( override fun invokeCommand(exchange: ServerCommandExchange<*>): Mono<*> = delegate .invoke(exchange) - .checkpoint( - "[${commandAggregate.aggregateId}] Invoke $qualifiedName Command[${exchange.message.id}] [CommandFunction]", - ) + .checkpoint(INVOKE_COMMAND_CHECKPOINT) override fun toString(): String = "CommandFunction(delegate=$delegate)" } + +internal class SimpleCommandFunction( + private val metadata: FunctionAccessorMetadata>, + commandAggregate: CommandAggregate, + afterCommandFunctions: List>, + override val supportedTopics: Set = setOf(commandAggregate.materialize()) +) : AbstractCommandFunction(commandAggregate, afterCommandFunctions) { + private companion object { + const val INVOKE_COMMAND_CHECKPOINT = "Invoke Command [CommandFunction]" + } + + override val contextName: String = metadata.contextName + override val name: String = metadata.name + override val supportedType: Class<*> = metadata.supportedType + override val processor: C = commandAggregate.commandRoot + override val functionKind: FunctionKind = metadata.functionKind + + override fun getAnnotation(annotationClass: Class): A? = + metadata.accessor.function.scanAnnotation(annotationClass.kotlin) + + override fun invokeCommand(exchange: ServerCommandExchange<*>): Mono<*> { + val firstArgument = metadata.extractFirstArgument(exchange) + return metadata.accessor + .invokeSingle(processor, firstArgument) + .checkpoint(INVOKE_COMMAND_CHECKPOINT) + } + + override fun toString(): String = "SimpleCommandFunction(metadata=$metadata)" +} diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultApplyResourceTagsFunction.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultApplyResourceTagsFunction.kt index d29006c36ba..e7e910c5cbf 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultApplyResourceTagsFunction.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultApplyResourceTagsFunction.kt @@ -15,15 +15,18 @@ package me.ahoo.wow.modeling.command import me.ahoo.wow.api.abac.DefaultApplyResourceTags import me.ahoo.wow.api.abac.DefaultResourceTagsApplied +import me.ahoo.wow.api.modeling.NamedAggregate import me.ahoo.wow.command.ServerCommandExchange import me.ahoo.wow.modeling.command.after.AfterCommandFunction +import me.ahoo.wow.modeling.materialize import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.toMono class DefaultApplyResourceTagsFunction( commandAggregate: CommandAggregate, - afterCommandFunctions: List> -) : InternalCommandFunction(commandAggregate, afterCommandFunctions) { + afterCommandFunctions: List>, + supportedTopics: Set = setOf(commandAggregate.materialize()) +) : InternalCommandFunction(commandAggregate, afterCommandFunctions, supportedTopics) { override val supportedType: Class<*> = DefaultApplyResourceTags::class.java override fun invokeCommand(exchange: ServerCommandExchange<*>): Mono<*> { diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultDeleteAggregateFunction.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultDeleteAggregateFunction.kt index d66962bc984..d37e1eb35c3 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultDeleteAggregateFunction.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultDeleteAggregateFunction.kt @@ -15,8 +15,10 @@ package me.ahoo.wow.modeling.command import me.ahoo.wow.api.command.DefaultDeleteAggregate import me.ahoo.wow.api.event.DefaultAggregateDeleted +import me.ahoo.wow.api.modeling.NamedAggregate import me.ahoo.wow.command.ServerCommandExchange import me.ahoo.wow.modeling.command.after.AfterCommandFunction +import me.ahoo.wow.modeling.materialize import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.toMono @@ -32,8 +34,9 @@ import reactor.kotlin.core.publisher.toMono */ class DefaultDeleteAggregateFunction( commandAggregate: CommandAggregate, - afterCommandFunctions: List> -) : InternalCommandFunction(commandAggregate, afterCommandFunctions) { + afterCommandFunctions: List>, + supportedTopics: Set = setOf(commandAggregate.materialize()) +) : InternalCommandFunction(commandAggregate, afterCommandFunctions, supportedTopics) { override val supportedType: Class<*> = DefaultDeleteAggregate::class.java /** diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultRecoverAggregateFunction.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultRecoverAggregateFunction.kt index 834ef02f6c4..8de898a7aeb 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultRecoverAggregateFunction.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/DefaultRecoverAggregateFunction.kt @@ -15,8 +15,10 @@ package me.ahoo.wow.modeling.command import me.ahoo.wow.api.command.DefaultRecoverAggregate import me.ahoo.wow.api.event.DefaultAggregateRecovered +import me.ahoo.wow.api.modeling.NamedAggregate import me.ahoo.wow.command.ServerCommandExchange import me.ahoo.wow.modeling.command.after.AfterCommandFunction +import me.ahoo.wow.modeling.materialize import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.toMono @@ -32,8 +34,9 @@ import reactor.kotlin.core.publisher.toMono */ class DefaultRecoverAggregateFunction( commandAggregate: CommandAggregate, - afterCommandFunctions: List> -) : InternalCommandFunction(commandAggregate, afterCommandFunctions) { + afterCommandFunctions: List>, + supportedTopics: Set = setOf(commandAggregate.materialize()) +) : InternalCommandFunction(commandAggregate, afterCommandFunctions, supportedTopics) { override val supportedType: Class<*> = DefaultRecoverAggregate::class.java /** diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/InternalCommandFunction.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/InternalCommandFunction.kt index 4f832bfd18a..f724f617bce 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/InternalCommandFunction.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/InternalCommandFunction.kt @@ -20,10 +20,10 @@ import me.ahoo.wow.modeling.materialize abstract class InternalCommandFunction( commandAggregate: CommandAggregate, - afterCommandFunctions: List> + afterCommandFunctions: List>, + override val supportedTopics: Set = setOf(commandAggregate.materialize()) ) : AbstractCommandFunction(commandAggregate, afterCommandFunctions) { override val contextName: String = commandAggregate.contextName - override val supportedTopics: Set = setOf(commandAggregate.materialize()) override val processor: C = commandAggregate.commandRoot override val name: String by lazy { "${processor.javaClass.simpleName}.${supportedType.simpleName}" diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessor.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessor.kt index ab6fce18920..4a7a23ff4a5 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessor.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessor.kt @@ -38,19 +38,18 @@ class RetryableAggregateProcessor( private val log = KotlinLogging.logger {} private const val MAX_RETRIES = 3L private val MIN_BACKOFF = Duration.ofMillis(500) + private val RETRY_STRATEGY: Retry = Retry.backoff(MAX_RETRIES, MIN_BACKOFF) + .filter { + it.recoverable == RecoverableType.RECOVERABLE + }.doBeforeRetry { + log.warn(it.failure()) { + "[BeforeRetry] totalRetries[${it.totalRetries()}]." + } + } } override val processorName: String = RetryableAggregateProcessor::class.simpleName!! - private val retryStrategy: Retry = Retry.backoff(MAX_RETRIES, MIN_BACKOFF) - .filter { - it.recoverable == RecoverableType.RECOVERABLE - }.doBeforeRetry { - log.warn(it.failure()) { - "[BeforeRetry] $aggregateId totalRetries[${it.totalRetries()}]." - } - } - override fun process(exchange: ServerCommandExchange<*>): Mono { val stateAggregateMono = if (exchange.message.isCreate) { aggregateFactory.createAsMono(aggregateMetadata.state, exchange.message.aggregateId) @@ -67,6 +66,6 @@ class RetryableAggregateProcessor( exchange.clearError() it.process(exchange) } - .retryWhen(retryStrategy) + .retryWhen(RETRY_STRATEGY) } } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/SimpleCommandAggregate.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/SimpleCommandAggregate.kt index 29c9f760240..f02fcfd56dd 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/SimpleCommandAggregate.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/SimpleCommandAggregate.kt @@ -59,7 +59,6 @@ class SimpleCommandAggregate( processorName = processorName, name = SimpleCommandAggregate<*, *>::process.name, ) - private val commandFunctionRegistry = metadata.toCommandFunctionRegistry(this) private val errorFunctionRegistry = metadata.toErrorFunctionRegistry(this) @Volatile @@ -117,7 +116,7 @@ class SimpleCommandAggregate( state.aggregateId, ).toMono() } - val commandFunction = commandFunctionRegistry[commandType] + val commandFunction = metadata.toCommandFunction(this, commandType) requireNotNull(commandFunction) { "Failed to process command[${message.id}]: Undefined command[${message.body.javaClass}]." } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/AggregateProcessorFilter.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/AggregateProcessorFilter.kt index b05f904723e..5906cd5414a 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/AggregateProcessorFilter.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/AggregateProcessorFilter.kt @@ -27,6 +27,10 @@ class AggregateProcessorFilter( private val serviceProvider: ServiceProvider, private val aggregateProcessorFactory: AggregateProcessorFactory, ) : CommandFilter { + private companion object { + const val PROCESS_COMMAND_CHECKPOINT = "Process Command [AggregateProcessorFilter]" + } + override fun filter( exchange: ServerCommandExchange<*>, next: FilterChain> @@ -41,9 +45,7 @@ class AggregateProcessorFilter( exchange.setAggregateProcessor(aggregateProcessor) return aggregateProcessor .process(exchange) - .checkpoint( - "[${aggregateProcessor.aggregateId}] Process Command[${exchange.message.id}] [AggregateProcessorFilter]" - ) + .checkpoint(PROCESS_COMMAND_CHECKPOINT) .finallyAck(exchange) .then(Mono.defer { next.filter(exchange) }) } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/SendDomainEventStreamFilter.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/SendDomainEventStreamFilter.kt index c972b8cef40..78816bd5a25 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/SendDomainEventStreamFilter.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/command/dispatcher/SendDomainEventStreamFilter.kt @@ -27,6 +27,7 @@ class SendDomainEventStreamFilter( ) : CommandFilter { companion object { private val log = KotlinLogging.logger {} + private const val SEND_EVENT_STREAM_CHECKPOINT = "Send Message [SendDomainEventStreamFilter]" } override fun filter( @@ -40,7 +41,7 @@ class SendDomainEventStreamFilter( return@defer next.filter(exchange) } domainEventBus.send(eventStream) - .checkpoint("Send Message[${eventStream.id}] [SendDomainEventStreamFilter]") + .checkpoint(SEND_EVENT_STREAM_CHECKPOINT) .then(Mono.defer { next.filter(exchange) }) } } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadata.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadata.kt index 8319305a774..6ab7e8c1518 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadata.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadata.kt @@ -35,8 +35,11 @@ import me.ahoo.wow.modeling.command.CommandFunction import me.ahoo.wow.modeling.command.DefaultApplyResourceTagsFunction import me.ahoo.wow.modeling.command.DefaultDeleteAggregateFunction import me.ahoo.wow.modeling.command.DefaultRecoverAggregateFunction +import me.ahoo.wow.modeling.command.SimpleCommandFunction +import me.ahoo.wow.modeling.command.after.AfterCommandFunction import me.ahoo.wow.modeling.command.after.AfterCommandFunctionMetadata import me.ahoo.wow.modeling.command.after.AfterCommandFunctionMetadata.Companion.toAfterCommandFunction +import me.ahoo.wow.modeling.materialize import reactor.core.publisher.Mono /** @@ -74,6 +77,8 @@ data class CommandAggregateMetadata( */ override val processorName: String = aggregateType.simpleName + private val supportedTopics: Set = setOf(namedAggregate.materialize()) + /** * Indicates whether delete aggregate functionality is registered for this command aggregate. * @@ -108,53 +113,122 @@ data class CommandAggregateMetadata( (commandFunctionRegistry.keys.toList() + mountedCommands).sortedByOrder() } + fun toCommandFunction( + commandAggregate: CommandAggregate, + commandType: Class<*> + ): MessageFunction, Mono>? { + commandFunctionRegistry[commandType]?.let { functionMetadata -> + return functionMetadata.toCommandFunction( + commandType = commandType, + commandAggregate = commandAggregate, + allAfterCommandFunctions = commandAggregate.toAfterCommandFunctions(), + ) + } + + return commandAggregate.toDefaultCommandFunction(commandType) + } + fun toCommandFunctionRegistry( commandAggregate: CommandAggregate ): Map, MessageFunction, Mono>> { - val allAfterCommandFunction = - afterCommandFunctionRegistry.map { - it.toAfterCommandFunction(commandAggregate.commandRoot) - } + val allAfterCommandFunctions = commandAggregate.toAfterCommandFunctions() + val registry = + LinkedHashMap, MessageFunction, Mono>>( + commandFunctionRegistry.size + DEFAULT_INTERNAL_COMMAND_COUNT, + ) + commandFunctionRegistry.forEach { (commandType, functionMetadata) -> + registry[commandType] = functionMetadata.toCommandFunction( + commandType = commandType, + commandAggregate = commandAggregate, + allAfterCommandFunctions = allAfterCommandFunctions, + ) + } - return buildMap { - commandFunctionRegistry - .map { - val actualMessageFunction = it.value - .toMessageFunction, Mono<*>>(commandAggregate.commandRoot) - val afterCommandFunctions = allAfterCommandFunction - .filter { function -> function.metadata.supportCommand(it.key) } - it.key to CommandFunction(actualMessageFunction, commandAggregate, afterCommandFunctions) - }.also { - putAll(it) - } - - if (!registeredRecoverAggregate) { - val afterCommandFunctions = allAfterCommandFunction - .filter { function -> function.metadata.supportCommand(DefaultRecoverAggregate::class.java) } - put( - DefaultRecoverAggregate::class.java, - DefaultRecoverAggregateFunction(commandAggregate, afterCommandFunctions), - ) + commandAggregate.toDefaultCommandFunction( + commandType = DefaultRecoverAggregate::class.java, + allAfterCommandFunctions = allAfterCommandFunctions, + )?.let { + registry[DefaultRecoverAggregate::class.java] = it + } + commandAggregate.toDefaultCommandFunction( + commandType = DefaultDeleteAggregate::class.java, + allAfterCommandFunctions = allAfterCommandFunctions, + )?.let { + registry[DefaultDeleteAggregate::class.java] = it + } + commandAggregate.toDefaultCommandFunction( + commandType = DefaultApplyResourceTags::class.java, + allAfterCommandFunctions = allAfterCommandFunctions, + )?.let { + registry[DefaultApplyResourceTags::class.java] = it + } + return registry + } + + private fun FunctionAccessorMetadata>.toCommandFunction( + commandType: Class<*>, + commandAggregate: CommandAggregate, + allAfterCommandFunctions: List> + ): MessageFunction, Mono> { + val afterCommandFunctions = allAfterCommandFunctions.supportCommand(commandType) + if (injectParameterLength == 0) { + return SimpleCommandFunction( + metadata = this, + commandAggregate = commandAggregate, + afterCommandFunctions = afterCommandFunctions, + supportedTopics = supportedTopics, + ) + } + val actualMessageFunction = toMessageFunction, Mono<*>>( + commandAggregate.commandRoot, + ) + return CommandFunction( + actualMessageFunction, + commandAggregate, + afterCommandFunctions, + supportedTopics, + ) + } + + private fun CommandAggregate.toDefaultCommandFunction( + commandType: Class<*>, + allAfterCommandFunctions: List> = toAfterCommandFunctions() + ): MessageFunction, Mono>? { + return when { + commandType == DefaultRecoverAggregate::class.java && !registeredRecoverAggregate -> { + val afterCommandFunctions = allAfterCommandFunctions.supportCommand(DefaultRecoverAggregate::class.java) + DefaultRecoverAggregateFunction(this, afterCommandFunctions, supportedTopics) } - if (!registeredDeleteAggregate) { - val afterCommandFunctions = allAfterCommandFunction - .filter { function -> function.metadata.supportCommand(DefaultDeleteAggregate::class.java) } - put( - DefaultDeleteAggregate::class.java, - DefaultDeleteAggregateFunction(commandAggregate, afterCommandFunctions), - ) + + commandType == DefaultDeleteAggregate::class.java && !registeredDeleteAggregate -> { + val afterCommandFunctions = allAfterCommandFunctions.supportCommand(DefaultDeleteAggregate::class.java) + DefaultDeleteAggregateFunction(this, afterCommandFunctions, supportedTopics) } - if (!registeredApplyResourceTags) { - val afterCommandFunctions = allAfterCommandFunction - .filter { function -> function.metadata.supportCommand(DefaultApplyResourceTags::class.java) } - put( + + commandType == DefaultApplyResourceTags::class.java && !registeredApplyResourceTags -> { + val afterCommandFunctions = allAfterCommandFunctions.supportCommand( DefaultApplyResourceTags::class.java, - DefaultApplyResourceTagsFunction(commandAggregate, afterCommandFunctions), ) + DefaultApplyResourceTagsFunction(this, afterCommandFunctions, supportedTopics) } + + else -> null } } + private fun CommandAggregate.toAfterCommandFunctions(): List> { + if (afterCommandFunctionRegistry.isEmpty()) { + return emptyList() + } + return afterCommandFunctionRegistry.map { + it.toAfterCommandFunction(commandRoot) + } + } + + private companion object { + const val DEFAULT_INTERNAL_COMMAND_COUNT = 3 + } + /** * Converts the error function registry into executable message functions. * @@ -165,13 +239,27 @@ data class CommandAggregateMetadata( */ fun toErrorFunctionRegistry( commandAggregate: CommandAggregate - ): Map, MessageFunction, Mono<*>>> = - errorFunctionRegistry - .map { - val actualMessageFunction = it.value + ): Map, MessageFunction, Mono<*>>> { + if (errorFunctionRegistry.isEmpty()) { + return emptyMap() + } + return buildMap(errorFunctionRegistry.size) { + errorFunctionRegistry.forEach { (errorType, functionMetadata) -> + val actualMessageFunction = functionMetadata .toMessageFunction, Mono<*>>(commandAggregate.commandRoot) - it.key to actualMessageFunction - }.toMap() + put(errorType, actualMessageFunction) + } + } + } + + private fun List>.supportCommand(commandType: Class<*>): List> { + if (isEmpty()) { + return emptyList() + } + return filter { function -> + function.metadata.supportCommand(commandType) + } + } override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadata.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadata.kt index 552143e806e..3984e23f043 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadata.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadata.kt @@ -12,10 +12,13 @@ */ package me.ahoo.wow.modeling.metadata +import me.ahoo.wow.api.event.DomainEvent import me.ahoo.wow.api.modeling.TypedAggregate import me.ahoo.wow.event.DomainEventExchange +import me.ahoo.wow.event.SimpleDomainEventExchange import me.ahoo.wow.infra.accessor.constructor.ConstructorAccessor import me.ahoo.wow.infra.accessor.property.PropertyGetter +import me.ahoo.wow.messaging.function.FirstParameterKind import me.ahoo.wow.messaging.function.FunctionAccessorMetadata import me.ahoo.wow.messaging.function.MessageFunction import me.ahoo.wow.messaging.function.toMessageFunction @@ -54,10 +57,53 @@ data class StateAggregateMetadata( * @return A map of event classes to their message functions. */ fun toMessageFunctionRegistry(stateRoot: S): Map, MessageFunction, Void>> = - sourcingFunctionRegistry - .map { - it.key to it.value.toMessageFunction, Void>(stateRoot) - }.toMap() + buildMap(sourcingFunctionRegistry.size) { + sourcingFunctionRegistry.forEach { (eventType, functionMetadata) -> + put(eventType, functionMetadata.toMessageFunction, Void>(stateRoot)) + } + } + + fun sourcing( + stateRoot: S, + domainEvent: DomainEvent<*> + ): Boolean { + val functionMetadata = sourcingFunctionRegistry[domainEvent.body.javaClass] ?: return false + functionMetadata.invokeSourcing(stateRoot, domainEvent) + return true + } + + private fun FunctionAccessorMetadata.invokeSourcing( + stateRoot: S, + domainEvent: DomainEvent<*> + ) { + if (injectParameterLength == 0) { + accessor.invokeSingle(stateRoot, firstArgument(domainEvent)) + return + } + val exchange = domainEvent.toExchange() + val args = arrayOfNulls(1 + injectParameterLength) + args[0] = extractFirstArgument(exchange) + for (i in 0 until injectParameterLength) { + val injectParameter = injectParameters[i] + if (injectParameter.name.isNotBlank()) { + args[i + 1] = exchange.getServiceProvider()?.getService(injectParameter.name) + } else { + args[i + 1] = exchange.extractObject(injectParameter.type) + } + } + accessor.invoke(stateRoot, args) + } + + private fun FunctionAccessorMetadata.firstArgument(domainEvent: DomainEvent<*>): Any = + when (firstParameterKind) { + FirstParameterKind.MESSAGE_EXCHANGE -> domainEvent.toExchange() + FirstParameterKind.MESSAGE -> domainEvent + FirstParameterKind.MESSAGE_BODY -> domainEvent.body + } + + @Suppress("UNCHECKED_CAST") + private fun DomainEvent<*>.toExchange(): SimpleDomainEventExchange = + SimpleDomainEventExchange(this as DomainEvent) override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/state/SimpleStateAggregate.kt b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/state/SimpleStateAggregate.kt index 4b4724bbaba..4210882f1c3 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/modeling/state/SimpleStateAggregate.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/modeling/state/SimpleStateAggregate.kt @@ -30,7 +30,6 @@ import me.ahoo.wow.api.modeling.TypedAggregate import me.ahoo.wow.api.modeling.aware.VersionAware import me.ahoo.wow.command.CommandOperator.operator import me.ahoo.wow.event.DomainEventStream -import me.ahoo.wow.event.SimpleDomainEventExchange import me.ahoo.wow.event.ignoreSourcing import me.ahoo.wow.modeling.metadata.StateAggregateMetadata @@ -69,8 +68,6 @@ class SimpleStateAggregate( override var deleted: Boolean = false ) : StateAggregate, TypedAggregate by metadata { - private val sourcingRegistry = metadata.toMessageFunctionRegistry(state) - companion object { private val log = KotlinLogging.logger {} } @@ -171,10 +168,7 @@ class SimpleStateAggregate( if (domainEventBody is ResourceTagsApplied) { tags = domainEventBody.tags } - val sourcingFunction = sourcingRegistry[domainEvent.body.javaClass] - if (sourcingFunction != null) { - sourcingFunction.invoke(SimpleDomainEventExchange(domainEvent)) - } else { + if (!metadata.sourcing(state, domainEvent)) { log.debug { "Sourcing $domainEvent Ignore this domain event because onSourcing does not exist." } diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/DomainEventRecord.kt b/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/DomainEventRecord.kt index 4739da8dcfd..e0326e3929c 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/DomainEventRecord.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/DomainEventRecord.kt @@ -20,6 +20,7 @@ import me.ahoo.wow.event.upgrader.EventUpgraderFactory import me.ahoo.wow.infra.TypeNameMapper.toType import me.ahoo.wow.modeling.MaterializedNamedAggregate import me.ahoo.wow.modeling.aggregateId +import me.ahoo.wow.serialization.JsonSerializer import me.ahoo.wow.serialization.MessageAggregateIdRecord import me.ahoo.wow.serialization.MessageAggregateNameRecord import me.ahoo.wow.serialization.MessageBodyRecord @@ -31,8 +32,10 @@ import me.ahoo.wow.serialization.MessageVersionRecord import me.ahoo.wow.serialization.NamedBoundedContextMessageRecord import me.ahoo.wow.serialization.OwnerIdRecord import me.ahoo.wow.serialization.SpaceIdRecord -import me.ahoo.wow.serialization.toObject +import tools.jackson.databind.JsonNode +import tools.jackson.databind.ObjectReader import tools.jackson.databind.node.ObjectNode +import java.util.concurrent.ConcurrentHashMap object DomainEventRecords { const val SEQUENCE = "sequence" @@ -40,6 +43,16 @@ object DomainEventRecords { const val IS_LAST = "isLast" } +private object DomainEventBodyReaders { + private val readers = ConcurrentHashMap() + + fun read(bodyType: String, body: JsonNode): Any { + return readers.computeIfAbsent(bodyType) { + JsonSerializer.readerFor(it.toType()) + }.readValue(body) + } +} + interface StreamEventRecord : MessageIdRecord, MessageBodyTypeRecord, MessageBodyRecord, MessageNameRecord { val revision: String get() = actual[DomainEventRecords.REVISION].asText() @@ -74,14 +87,16 @@ interface DomainEventRecord : private fun toDomainEventObject(): DomainEvent { val aggregateId = toAggregateId() - val bodyType = try { - bodyType.toType() + val bodyTypeName = bodyType + val body = body + val eventBody = try { + DomainEventBodyReaders.read(bodyTypeName, body) } catch (classNotFoundException: ClassNotFoundException) { @Suppress("UNCHECKED_CAST") return JsonDomainEvent( id = id, header = toMessageHeader(), - bodyType = bodyType, + bodyType = bodyTypeName, body = body, aggregateId = aggregateId, ownerId = ownerId, @@ -98,7 +113,7 @@ interface DomainEventRecord : return SimpleDomainEvent( id = id, header = toMessageHeader(), - body = body.toObject(bodyType), + body = eventBody, aggregateId = aggregateId, ownerId = ownerId, spaceId = spaceId, diff --git a/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/EventStreamRecord.kt b/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/EventStreamRecord.kt index c86432a2464..50a92349b04 100644 --- a/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/EventStreamRecord.kt +++ b/wow-core/src/main/kotlin/me/ahoo/wow/serialization/event/EventStreamRecord.kt @@ -14,6 +14,7 @@ package me.ahoo.wow.serialization.event import me.ahoo.wow.api.event.DEFAULT_EVENT_SEQUENCE +import me.ahoo.wow.api.event.DomainEvent import me.ahoo.wow.api.messaging.Header import me.ahoo.wow.api.modeling.AggregateId import me.ahoo.wow.api.modeling.SpaceId @@ -60,21 +61,24 @@ interface EventStreamRecord : val createTime = createTime val aggregateId = toAggregateId() val eventCount = body.size() - val events = body.mapIndexed { index, eventNode -> + val events = ArrayList>(eventCount) + body.forEachIndexed { index, eventNode -> val sequence = (index + DEFAULT_EVENT_SEQUENCE) - StreamDomainEventRecord( - actual = eventNode as ObjectNode, - streamedAggregateId = aggregateId, - version = version, - ownerId = ownerId, - spaceId = spaceId, - streamedHeader = header, - commandId = commandId, - sequence = sequence, - isLast = sequence == eventCount, - createTime = createTime, - ).toDomainEvent() - }.toList() + events.add( + StreamDomainEventRecord( + actual = eventNode as ObjectNode, + streamedAggregateId = aggregateId, + version = version, + ownerId = ownerId, + spaceId = spaceId, + streamedHeader = header, + commandId = commandId, + sequence = sequence, + isLast = sequence == eventCount, + createTime = createTime, + ).toDomainEvent() + ) + } return SimpleDomainEventStream( id = id, diff --git a/wow-core/src/main/resources/META-INF/services/tools.jackson.databind.JacksonModule b/wow-core/src/main/resources/META-INF/services/tools.jackson.databind.JacksonModule index cd9fad12648..58dce9bbe5a 100644 --- a/wow-core/src/main/resources/META-INF/services/tools.jackson.databind.JacksonModule +++ b/wow-core/src/main/resources/META-INF/services/tools.jackson.databind.JacksonModule @@ -11,4 +11,4 @@ # limitations under the License. # -me.ahoo.wow.serialization.WowModule \ No newline at end of file +me.ahoo.wow.serialization.WowModule diff --git a/wow-core/src/test/java/me/ahoo/wow/infra/accessor/function/FastInvokeTest.java b/wow-core/src/test/java/me/ahoo/wow/infra/accessor/function/FastInvokeTest.java index 53da8de97a0..0f569e91d49 100644 --- a/wow-core/src/test/java/me/ahoo/wow/infra/accessor/function/FastInvokeTest.java +++ b/wow-core/src/test/java/me/ahoo/wow/infra/accessor/function/FastInvokeTest.java @@ -41,6 +41,10 @@ public String[] varArgsMethod(String... args) { return args; } + public String singleArgMethod(String arg) { + return "hello " + arg; + } + @Test void invoke() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { Method method = getClass().getDeclaredMethod("varArgsMethod", String[].class); @@ -51,6 +55,14 @@ void invoke() throws NoSuchMethodException, InvocationTargetException, IllegalAc assertThat(result).isEqualTo(args); } + @Test + void safeInvokeSingle() throws Throwable { + Method method = getClass().getDeclaredMethod("singleArgMethod", String.class); + method.trySetAccessible(); + Object result = FastInvoke.safeInvokeSingle(method, this, "wow"); + assertThat(result).isEqualTo("hello wow"); + } + @Test void newInstance() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { Constructor ctor = Ctor.class.getDeclaredConstructor(String.class); @@ -58,4 +70,4 @@ void newInstance() throws NoSuchMethodException, InvocationTargetException, Inst Ctor instance = FastInvoke.newInstance(ctor, new Object[]{"1"}); assertThat(instance.getId()).isEqualTo("1"); } -} \ No newline at end of file +} diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleClientCommandExchangeBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleClientCommandExchangeBehaviorTest.kt index ef46bf6b223..a1c087f7ddc 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleClientCommandExchangeBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleClientCommandExchangeBehaviorTest.kt @@ -19,6 +19,17 @@ import org.junit.jupiter.api.Test class SimpleClientCommandExchangeBehaviorTest { + @Test + fun `default exchange creates attribute map lazily`() { + val message = AccountCommand(id = "account-1").toCommandMessage(id = "command-1") + val waitStrategy = WaitingForStage.sent(message.commandId) + val exchange = SimpleClientCommandExchange(message, waitStrategy) + + exchange.eagerAttributeMaps().assert().isEmpty() + exchange.attributes["key"] = "value" + exchange.attributes["key"].assert().isEqualTo("value") + } + @Test fun `should expose message wait strategy and mutable attributes`() { val message = AccountCommand(id = "account-1").toCommandMessage(id = "command-1") @@ -34,4 +45,12 @@ class SimpleClientCommandExchangeBehaviorTest { exchange.getAttribute("key").assert().isEqualTo("value") } + + private fun Any.eagerAttributeMaps(): List> = + javaClass.declaredFields + .filter { Map::class.java.isAssignableFrom(it.type) } + .mapNotNull { field -> + field.isAccessible = true + field.get(this) as? Map<*, *> + } } diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleServerCommandExchangeBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleServerCommandExchangeBehaviorTest.kt index 2af3eaf3477..065f3028160 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleServerCommandExchangeBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/command/SimpleServerCommandExchangeBehaviorTest.kt @@ -20,14 +20,32 @@ import me.ahoo.wow.api.command.CommandMessage import me.ahoo.wow.api.event.DomainEvent import me.ahoo.wow.api.exception.DefaultErrorInfo import me.ahoo.wow.api.exception.ErrorInfo +import me.ahoo.wow.api.messaging.function.FunctionInfoData +import me.ahoo.wow.api.messaging.function.FunctionKind import me.ahoo.wow.event.DomainEventException import me.ahoo.wow.event.DomainEventStream +import me.ahoo.wow.messaging.handler.ERROR_KEY +import me.ahoo.wow.messaging.handler.FUNCTION_KEY import me.ahoo.wow.modeling.command.AggregateProcessor +import me.ahoo.wow.modeling.command.COMMAND_AGGREGATE_KEY +import me.ahoo.wow.modeling.command.CommandAggregate +import me.ahoo.wow.modeling.command.getCommandAggregate +import me.ahoo.wow.modeling.command.setCommandAggregate import me.ahoo.wow.modeling.metadata.AggregateMetadata import org.junit.jupiter.api.Test class SimpleServerCommandExchangeBehaviorTest { + @Test + fun `default exchange creates attribute map lazily`() { + val message = AccountCommand(id = "account-1").toCommandMessage() + val exchange = SimpleServerCommandExchange(message) + + exchange.eagerAttributeMaps().assert().isEmpty() + exchange.attributes["key"] = "value" + exchange.attributes["key"].assert().isEqualTo("value") + } + @Test fun `should store command processing attributes`() { val message = AccountCommand(id = "account-1").toCommandMessage() @@ -49,6 +67,47 @@ class SimpleServerCommandExchangeBehaviorTest { exchange.getAggregateVersion().assert().isEqualTo(7) } + @Test + fun `typed command attributes are field backed until attributes map is requested`() { + val message = AccountCommand(id = "account-1").toCommandMessage() + val exchange = SimpleServerCommandExchange(message) + val function = FunctionInfoData.unknown(FunctionKind.COMMAND, "context") + val aggregateMetadata = mockk>() + val aggregateProcessor = mockk>() + val commandAggregate = mockk>() + val eventStream = mockk() + val error = IllegalStateException("failed") + + exchange.setFunction(function) + exchange.setAggregateMetadata(aggregateMetadata) + exchange.setAggregateProcessor(aggregateProcessor) + exchange.setCommandAggregate(commandAggregate) + exchange.setCommandInvokeResult("handled") + exchange.setEventStream(eventStream) + exchange.setAggregateVersion(7) + exchange.setError(error) + + exchange.eagerAttributeMaps().assert().isEmpty() + exchange.getFunction().assert().isSameAs(function) + exchange.getAggregateMetadata().assert().isSameAs(aggregateMetadata) + exchange.getAggregateProcessor().assert().isSameAs(aggregateProcessor) + exchange.getCommandAggregate().assert().isSameAs(commandAggregate) + exchange.getCommandInvokeResult().assert().isEqualTo("handled") + exchange.getEventStream().assert().isSameAs(eventStream) + exchange.getAggregateVersion().assert().isEqualTo(7) + exchange.getError().assert().isSameAs(error) + + val attributes = exchange.attributes + attributes[FUNCTION_KEY].assert().isSameAs(function) + attributes[AGGREGATE_METADATA_KEY].assert().isSameAs(aggregateMetadata) + attributes[AGGREGATE_PROCESSOR_KEY].assert().isSameAs(aggregateProcessor) + attributes[COMMAND_AGGREGATE_KEY].assert().isSameAs(commandAggregate) + attributes[COMMAND_INVOKE_RESULT_KEY].assert().isEqualTo("handled") + attributes[EVENT_STREAM_KEY].assert().isSameAs(eventStream) + attributes[AGGREGATE_VERSION_KEY].assert().isEqualTo(7) + attributes[ERROR_KEY].assert().isSameAs(error) + } + @Test fun `should extract declared message header aggregate id event stream and error`() { val message = AccountCommand(id = "account-1").toCommandMessage() @@ -84,4 +143,12 @@ class SimpleServerCommandExchangeBehaviorTest { (error as DomainEventException).errorCode.assert().isEqualTo("EVENT_FAILED") error.errorMsg.assert().isEqualTo("event failed") } + + private fun Any.eagerAttributeMaps(): List> = + javaClass.declaredFields + .filter { Map::class.java.isAssignableFrom(it.type) } + .mapNotNull { field -> + field.isAccessible = true + field.get(this) as? Map<*, *> + } } diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/event/DomainEventStreamFactoryBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/event/DomainEventStreamFactoryBehaviorTest.kt index 4b4c56b59f6..6a8b3869f7e 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/event/DomainEventStreamFactoryBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/event/DomainEventStreamFactoryBehaviorTest.kt @@ -30,6 +30,44 @@ class DomainEventStreamFactoryBehaviorTest { FixtureNamedEvent("single").flatEvent().toList().assert().isEqualTo(listOf(FixtureNamedEvent("single"))) } + @Test + fun `toDomainEventStream creates single event from command context`() { + val aggregateId = FIXTURE_NAMED_AGGREGATE.toNamedAggregate().aggregateId("single-factory-aggregate") + val upstream = GivenInitializationCommand( + aggregateId = aggregateId, + id = "single-command", + ownerId = "", + spaceId = "", + requestId = "single-request", + ) + val header = DefaultHeader.empty().with("trace", "single-trace") + + val stream = FixtureNamedEvent("single").toDomainEventStream( + upstream = upstream, + aggregateVersion = 6, + stateOwnerId = "single-owner", + stateSpaceId = "single-space", + header = header, + createTime = 3000, + ) + val event = stream.body.single() + + stream.requestId.assert().isEqualTo("single-request") + stream.aggregateId.assert().isEqualTo(aggregateId) + stream.ownerId.assert().isEqualTo("single-owner") + stream.spaceId.assert().isEqualTo("single-space") + stream.version.assert().isEqualTo(7) + stream.createTime.assert().isEqualTo(3000) + stream.size.assert().isEqualTo(1) + event.sequence.assert().isEqualTo(DEFAULT_EVENT_SEQUENCE) + event.isLast.assert().isTrue() + event.commandId.assert().isEqualTo(upstream.commandId) + event.header["trace"].assert().isEqualTo("single-trace") + + event.header["trace"] = "event-local" + stream.header["trace"].assert().isEqualTo("single-trace") + } + @Test fun `toDomainEventStream creates sequenced events from command context`() { val aggregateId = FIXTURE_NAMED_AGGREGATE.toNamedAggregate().aggregateId("factory-aggregate") diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/event/SimpleDomainEventExchangeBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/event/SimpleDomainEventExchangeBehaviorTest.kt index a0336868a3a..948adbca342 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/event/SimpleDomainEventExchangeBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/event/SimpleDomainEventExchangeBehaviorTest.kt @@ -25,6 +25,7 @@ import me.ahoo.wow.tck.mock.MOCK_AGGREGATE_METADATA import me.ahoo.wow.tck.mock.MockStateAggregate import org.junit.jupiter.api.Test import reactor.core.publisher.Mono +import java.util.concurrent.ConcurrentHashMap class SimpleDomainEventExchangeBehaviorTest { @@ -66,6 +67,61 @@ class SimpleDomainEventExchangeBehaviorTest { exchange.extractDeclared(MockStateAggregate::class.java).assert().isSameAs(stateAggregate.state) } + @Test + fun `default exchanges create attribute maps lazily`() { + val exchange = SimpleDomainEventExchange(event) + + exchange.eagerAttributeMaps().assert().isEmpty() + exchange.attributes["key"] = "value" + exchange.attributes["key"].assert().isEqualTo("value") + + val stateAggregate = MOCK_AGGREGATE_METADATA.state.toStateAggregate( + aggregateId = aggregateId, + state = MockStateAggregate("exchange-aggregate"), + version = 1, + ) + val stateExchange = SimpleStateDomainEventExchange( + state = stateAggregate, + message = event, + ) + + stateExchange.eagerAttributeMaps().assert().isEmpty() + stateExchange.attributes["key"] = "value" + stateExchange.attributes["key"].assert().isEqualTo("value") + } + + @Test + fun `simple exchanges preserve supplied attribute maps`() { + val attributes = ConcurrentHashMap() + val exchange = SimpleDomainEventExchange( + message = event, + attributes = attributes, + ) + + exchange.attributes.assert().isSameAs(attributes) + + val stateAggregate = MOCK_AGGREGATE_METADATA.state.toStateAggregate( + aggregateId = aggregateId, + state = MockStateAggregate("exchange-aggregate"), + version = 1, + ) + val stateExchange = SimpleStateDomainEventExchange( + state = stateAggregate, + message = event, + attributes = attributes, + ) + + stateExchange.attributes.assert().isSameAs(attributes) + } + + private fun Any.eagerAttributeMaps(): List> = + javaClass.declaredFields + .filter { Map::class.java.isAssignableFrom(it.type) } + .mapNotNull { field -> + field.isAccessible = true + field.get(this) as? Map<*, *> + } + private class TestEventFunction : MessageFunction, Mono<*>> { override val name: String = "test" override val contextName: String = "event" diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/SimpleFunctionAccessorBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/SimpleFunctionAccessorBehaviorTest.kt index d62a9dc5663..c6043612893 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/SimpleFunctionAccessorBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/SimpleFunctionAccessorBehaviorTest.kt @@ -28,6 +28,7 @@ class SimpleFunctionAccessorBehaviorTest { accessor.name.assert().isEqualTo("greet") accessor.targetType.assert().isEqualTo(FunctionAccessorFixture::class.java) accessor.invoke(fixture, arrayOf("wow")).assert().isEqualTo("hello wow") + accessor.invokeSingle(fixture, "wow").assert().isEqualTo("hello wow") } } diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/reactive/MonoFunctionAccessorBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/reactive/MonoFunctionAccessorBehaviorTest.kt index 7f6e7309d3f..b443dbee0b3 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/reactive/MonoFunctionAccessorBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/infra/accessor/function/reactive/MonoFunctionAccessorBehaviorTest.kt @@ -46,6 +46,16 @@ class MonoFunctionAccessorBehaviorTest { .verifyComplete() } + @Test + fun `single argument sync accessor still adapts return value to Mono`() { + val accessor = ReactiveAccessorFixture::syncEcho.toMonoFunctionAccessor() + + accessor.assert().isInstanceOf(SyncMonoFunctionAccessor::class.java) + StepVerifier.create(accessor.invokeSingle(fixture, "single")) + .expectNext("single") + .verifyComplete() + } + @Test fun `should collect Flux return values into a list`() { val accessor = ReactiveAccessorFixture::fluxValues @@ -110,6 +120,8 @@ private class ReactiveAccessorFixture { fun syncValue(): String = syncResult + fun syncEcho(value: String): String = value + fun fluxValues(): Flux = Flux.just("flux-1", "flux-2") fun publisherValues(): Publisher = Flux.just("publisher-1", "publisher-2") diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/messaging/DefaultHeaderBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/messaging/DefaultHeaderBehaviorTest.kt index 34fddfe64e3..74c6ade7b2e 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/messaging/DefaultHeaderBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/messaging/DefaultHeaderBehaviorTest.kt @@ -15,6 +15,7 @@ package me.ahoo.wow.messaging import me.ahoo.test.asserts.assert import me.ahoo.test.asserts.assertThrownBy +import me.ahoo.wow.api.messaging.Header import org.junit.jupiter.api.Test class DefaultHeaderBehaviorTest { @@ -79,6 +80,27 @@ class DefaultHeaderBehaviorTest { source.containsKey("copy-only").assert().isFalse() } + @Test + fun `empty and copy use compact small map backing`() { + val source = DefaultHeader.empty() + .with("one", "1") + .with("two", "2") + + val copy = source.copy() + + source.backingMap().javaClass.simpleName.assert().isEqualTo("SmallHeaderMap") + copy.backingMap().javaClass.simpleName.assert().isEqualTo("SmallHeaderMap") + source.backingMap().arrayFieldCount().assert().isEqualTo(1) + copy.backingMap().arrayFieldCount().assert().isEqualTo(1) + copy.assert().isEqualTo(source) + copy.backingMap().slotArray().assert().isSameAs(source.backingMap().slotArray()) + + copy.with("copy-only", "true") + + source.containsKey("copy-only").assert().isFalse() + copy.backingMap().slotArray().assert().isNotSameAs(source.backingMap().slotArray()) + } + @Test fun `toHeader returns empty header for null and empty maps`() { val nullMap: Map? = null @@ -102,4 +124,23 @@ class DefaultHeaderBehaviorTest { copied["key"].assert().isEqualTo("value") } + + @Suppress("UNCHECKED_CAST") + private fun Header.backingMap(): MutableMap { + val field = DefaultHeader::class.java.getDeclaredField("delegate") + field.isAccessible = true + return field.get(this) as MutableMap + } + + private fun Any.arrayFieldCount(): Int { + return javaClass.declaredFields.count { + it.type.isArray && !java.lang.reflect.Modifier.isStatic(it.modifiers) + } + } + + private fun Any.slotArray(): Array<*> { + val field = javaClass.getDeclaredField("slots") + field.isAccessible = true + return field.get(this) as Array<*> + } } diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessorBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessorBehaviorTest.kt index 7016e7bd6f2..c622dfc6edc 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessorBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/modeling/command/RetryableAggregateProcessorBehaviorTest.kt @@ -34,6 +34,8 @@ import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.toMono import reactor.test.StepVerifier +import reactor.util.retry.Retry +import java.lang.reflect.Modifier import java.time.Duration import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicInteger @@ -92,6 +94,16 @@ class RetryableAggregateProcessorBehaviorTest { exchange.getError().assert().isNull() } + @Test + fun `processor reuses retry strategy across instances`() { + val perInstanceRetryFields = RetryableAggregateProcessor::class.java.declaredFields + .filter { + Retry::class.java.isAssignableFrom(it.type) && !Modifier.isStatic(it.modifiers) + } + + perInstanceRetryFields.map { it.name }.assert().isEmpty() + } + private fun processor( aggregateId: AggregateId, eventStore: EventStore, diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadataBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadataBehaviorTest.kt index 663c2be8ebb..871bb6b0e29 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadataBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/CommandAggregateMetadataBehaviorTest.kt @@ -18,6 +18,7 @@ import me.ahoo.wow.api.abac.DefaultApplyResourceTags import me.ahoo.wow.api.command.DefaultDeleteAggregate import me.ahoo.wow.api.command.DefaultRecoverAggregate import me.ahoo.wow.eventsourcing.InMemoryEventStore +import me.ahoo.wow.infra.Decorator import me.ahoo.wow.modeling.annotation.CreateCmd import me.ahoo.wow.modeling.annotation.MockAfterCommandAggregate import me.ahoo.wow.modeling.annotation.MockAggregate @@ -73,4 +74,31 @@ class CommandAggregateMetadataBehaviorTest { DefaultApplyResourceTags::class.java, ) } + + @Test + fun `command function resolves one command type and default internal commands`() { + val aggregateMetadata = aggregateMetadata() + val commandRoot = MockAfterCommandAggregate("aggregate-1") + val stateAggregate = aggregateMetadata.toStateAggregate(commandRoot, version = 0) + val commandAggregate = SimpleCommandAggregate( + state = stateAggregate, + commandRoot = commandRoot, + eventStore = InMemoryEventStore(), + metadata = aggregateMetadata.command, + ) + + val createFunction = aggregateMetadata.command.toCommandFunction(commandAggregate, CreateCmd::class.java) + val deleteFunction = aggregateMetadata.command.toCommandFunction( + commandAggregate, + DefaultDeleteAggregate::class.java, + ) + val missingFunction = aggregateMetadata.command.toCommandFunction(commandAggregate, String::class.java) + + createFunction.assert().isNotNull() + createFunction!!.supportedType.assert().isEqualTo(CreateCmd::class.java) + Decorator::class.java.isInstance(createFunction).assert().isFalse() + deleteFunction.assert().isNotNull() + deleteFunction!!.supportedType.assert().isEqualTo(DefaultDeleteAggregate::class.java) + missingFunction.assert().isNull() + } } diff --git a/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadataBehaviorTest.kt b/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadataBehaviorTest.kt index ebea8882766..ee493826fc3 100644 --- a/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadataBehaviorTest.kt +++ b/wow-core/src/test/kotlin/me/ahoo/wow/modeling/metadata/StateAggregateMetadataBehaviorTest.kt @@ -14,8 +14,12 @@ package me.ahoo.wow.modeling.metadata import me.ahoo.test.asserts.assert +import me.ahoo.wow.event.toDomainEventStream +import me.ahoo.wow.modeling.aggregateId import me.ahoo.wow.tck.mock.MOCK_AGGREGATE_METADATA +import me.ahoo.wow.tck.mock.MockAggregateChanged import me.ahoo.wow.tck.mock.MockStateAggregate +import me.ahoo.wow.test.aggregate.GivenInitializationCommand import org.junit.jupiter.api.Test class StateAggregateMetadataBehaviorTest { @@ -40,4 +44,23 @@ class StateAggregateMetadataBehaviorTest { it.processor.assert().isSameAs(state) } } + + @Test + fun `state aggregate metadata sources matching domain events directly`() { + val state = MockStateAggregate("aggregate-1") + val command = GivenInitializationCommand(MOCK_AGGREGATE_METADATA.aggregateId("aggregate-1")) + val matchedEvent = MockAggregateChanged("changed") + .toDomainEventStream(upstream = command) + .first() + val ignoredEvent = UnknownStateMetadataEvent + .toDomainEventStream(upstream = command) + .first() + + MOCK_AGGREGATE_METADATA.state.sourcing(state, matchedEvent).assert().isTrue() + MOCK_AGGREGATE_METADATA.state.sourcing(state, ignoredEvent).assert().isFalse() + + state.data.assert().isEqualTo("changed") + } + + private object UnknownStateMetadataEvent } diff --git a/wow-redis/src/main/kotlin/me/ahoo/wow/redis/RedisScripts.kt b/wow-redis/src/main/kotlin/me/ahoo/wow/redis/RedisScripts.kt new file mode 100644 index 00000000000..1386b5b9ac9 --- /dev/null +++ b/wow-redis/src/main/kotlin/me/ahoo/wow/redis/RedisScripts.kt @@ -0,0 +1,26 @@ +/* + * Copyright [2021-present] [ahoo wang (https://github.com/Ahoo-Wang)]. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package me.ahoo.wow.redis + +import org.springframework.core.io.ClassPathResource +import org.springframework.data.redis.core.script.RedisScript + +internal object RedisScripts { + fun load(resourceName: String, resultType: Class): RedisScript { + val script = ClassPathResource(resourceName).inputStream.bufferedReader(Charsets.UTF_8).use { + it.readText() + } + return RedisScript.of(script, resultType) + } +} diff --git a/wow-redis/src/main/kotlin/me/ahoo/wow/redis/eventsourcing/RedisEventStore.kt b/wow-redis/src/main/kotlin/me/ahoo/wow/redis/eventsourcing/RedisEventStore.kt index 0f87a4d7b0b..58dcabd8912 100644 --- a/wow-redis/src/main/kotlin/me/ahoo/wow/redis/eventsourcing/RedisEventStore.kt +++ b/wow-redis/src/main/kotlin/me/ahoo/wow/redis/eventsourcing/RedisEventStore.kt @@ -20,11 +20,10 @@ import me.ahoo.wow.eventsourcing.AbstractEventStore import me.ahoo.wow.eventsourcing.EventVersionConflictException import me.ahoo.wow.exception.ErrorCodes import me.ahoo.wow.naming.getContextAlias +import me.ahoo.wow.redis.RedisScripts import me.ahoo.wow.redis.eventsourcing.EventStreamKeyConverter.toKey import me.ahoo.wow.serialization.toJsonString import me.ahoo.wow.serialization.toObject -import org.springframework.core.io.ClassPathResource -import org.springframework.core.io.Resource import org.springframework.data.domain.Range import org.springframework.data.redis.connection.Limit import org.springframework.data.redis.core.ReactiveStringRedisTemplate @@ -36,9 +35,8 @@ class RedisEventStore( private val redisTemplate: ReactiveStringRedisTemplate ) : AbstractEventStore() { companion object { - private val RESOURCE_EVENT_STEAM_APPEND: Resource = ClassPathResource("event_steam_append.lua") val SCRIPT_EVENT_STEAM_APPEND: RedisScript = - RedisScript.of(RESOURCE_EVENT_STEAM_APPEND, String::class.java) + RedisScripts.load("event_steam_append.lua", String::class.java) } override fun appendStream(eventStream: DomainEventStream): Mono { diff --git a/wow-redis/src/main/kotlin/me/ahoo/wow/redis/prepare/RedisPrepareKey.kt b/wow-redis/src/main/kotlin/me/ahoo/wow/redis/prepare/RedisPrepareKey.kt index 5be7797bbd8..8f57542c1f7 100644 --- a/wow-redis/src/main/kotlin/me/ahoo/wow/redis/prepare/RedisPrepareKey.kt +++ b/wow-redis/src/main/kotlin/me/ahoo/wow/redis/prepare/RedisPrepareKey.kt @@ -16,12 +16,11 @@ package me.ahoo.wow.redis.prepare import me.ahoo.wow.infra.prepare.PrepareKey import me.ahoo.wow.infra.prepare.PreparedValue import me.ahoo.wow.infra.prepare.PreparedValue.Companion.toTtlAt +import me.ahoo.wow.redis.RedisScripts import me.ahoo.wow.redis.eventsourcing.RedisWrappedKey.wrap import me.ahoo.wow.redis.prepare.PrepareKeyConverter.toKey import me.ahoo.wow.serialization.toJsonString import me.ahoo.wow.serialization.toObject -import org.springframework.core.io.ClassPathResource -import org.springframework.core.io.Resource import org.springframework.data.redis.core.ReactiveStringRedisTemplate import org.springframework.data.redis.core.script.RedisScript import reactor.core.publisher.Mono @@ -35,27 +34,20 @@ class RedisPrepareKey( private val redisTemplate: ReactiveStringRedisTemplate ) : PrepareKey { companion object { - private val RESOURCE_PREPARE_PREPARE: Resource = ClassPathResource("prepare_prepare.lua") val SCRIPT_PREPARE_PREPARE: RedisScript = - RedisScript.of(RESOURCE_PREPARE_PREPARE, Boolean::class.java) + RedisScripts.load("prepare_prepare.lua", Boolean::class.java) - private val RESOURCE_PREPARE_REPREPARE: Resource = ClassPathResource("prepare_reprepare.lua") val SCRIPT_PREPARE_REPREPARE: RedisScript = - RedisScript.of(RESOURCE_PREPARE_REPREPARE, Boolean::class.java) + RedisScripts.load("prepare_reprepare.lua", Boolean::class.java) - private val RESOURCE_PREPARE_REPREPARE_WITH_OLD_VALUE: Resource = - ClassPathResource("prepare_reprepare_with_old_value.lua") val SCRIPT_PREPARE_REPREPARE_WITH_OLD_VALUE: RedisScript = - RedisScript.of(RESOURCE_PREPARE_REPREPARE_WITH_OLD_VALUE, Boolean::class.java) + RedisScripts.load("prepare_reprepare_with_old_value.lua", Boolean::class.java) - private val RESOURCE_PREPARE_ROLLBACK: Resource = ClassPathResource("prepare_rollback.lua") val SCRIPT_PREPARE_ROLLBACK: RedisScript = - RedisScript.of(RESOURCE_PREPARE_ROLLBACK, Boolean::class.java) + RedisScripts.load("prepare_rollback.lua", Boolean::class.java) - private val RESOURCE_PREPARE_ROLLBACK_WITH_OLD_VALUE: Resource = - ClassPathResource("prepare_rollback_with_old_value.lua") val SCRIPT_PREPARE_ROLLBACK_WITH_OLD_VALUE: RedisScript = - RedisScript.of(RESOURCE_PREPARE_ROLLBACK_WITH_OLD_VALUE, Boolean::class.java) + RedisScripts.load("prepare_rollback_with_old_value.lua", Boolean::class.java) } private fun Map.decode(): PreparedValue? {