diff --git a/README.md b/README.md
index c8015065..fb9deeb1 100644
--- a/README.md
+++ b/README.md
@@ -600,7 +600,7 @@ Schema, table, column, identifier, and relationship names may contain environmen
- `core`: runtime ORM, H2 cache, PostgreSQL synchronization, and Redis integration
- `processor`: Java annotation processor that creates builders and query builders
- `intellij-plugin`: IntelliJ IDEA awareness for the generated API
-- `benchmark`: JMH benchmarks
+- `benchmark`: JMH microbenchmarks and container-backed Minecraft workloads; see [`benchmark/README.md`](benchmark/README.md)
- `utils`: shared internal utilities
The project is currently published as a snapshot. Expect API and behavior changes between snapshot versions.
diff --git a/benchmark/PERFORMANCE.md b/benchmark/PERFORMANCE.md
new file mode 100644
index 00000000..d656f396
--- /dev/null
+++ b/benchmark/PERFORMANCE.md
@@ -0,0 +1,80 @@
+# Current performance baseline
+
+This document records measurements of the current Static Data implementation. Results are machine-specific and should be compared on the same idle host, JVM, benchmark parameters, and commit.
+
+## Read throughput
+
+`ReadThroughputBenchmark` reports explicit operations per second for production read paths. The following results used Java 21, eight reader threads, and a retained, prewarmed 100-player set. Each player has one settings reference and eight friends.
+
+A compound player read performs a player instance lookup, resolves the settings reference and underlying settings instance, then reads the settings priority and player name.
+
+| Read operation | Throughput |
+| --- | ---: |
+| Instance-cache lookup | 25.94 ± 6.07 million ops/s |
+| Player lookup + persistent value | 12.79 ± 1.11 million ops/s |
+| Player lookup + settings reference | 12.35 ± 1.65 million ops/s |
+| Compound player read | 5.72 ± 0.46 million ops/s |
+| Friend collection | 0.497 ± 0.040 million ops/s |
+| Complete 100-player scan | 3,777 ± 258 scans/s |
+
+Working-set retention is parameterized. With eight readers selecting across 1,000 players, compound throughput was 4.33 ± 0.77 million reads/s when the complete set was retained and prewarmed. It was 3.94 ± 0.50 million reads/s when only the 100-player tick set was retained and the other 900 entries could be reclaimed between iterations.
+
+## End-to-end player scan
+
+`StaticDataBenchmark` uses the current DataManager, H2 mirror, PostgreSQL 16.2, and Redis 7.4.1. It creates 32 players with settings and friends, then runs one simulated Minecraft server thread alongside four cache-reader threads.
+
+| Operation | Result |
+| --- | ---: |
+| Hot `DataManager.getInstance()` hit | 0.06 ± 0.02 µs/op |
+| Complete 32-player scan under asynchronous load | 538.22 ± 101.91 µs/op |
+
+The modeled scan consumes about 0.54 ms, or 1.1% of a 50 ms tick budget, on the measurement host. It is a regression workload, not a production TPS prediction.
+
+## Cross-container load
+
+`CrossContainerLoadBenchmark` uses 100 players with eight friends and one settings reference per player. Peer sessions update PostgreSQL rows across disjoint partitions of the configured write set. Each committed update traverses the PostgreSQL trigger, `NOTIFY`, the full Static Data listener, H2 application, cache invalidation, and subsequent reads. Seven additional listener connections model notification fan-out.
+
+The mixed-load runs use 50 writes/s per peer session:
+
+| Remote load | Tick mean | Tick p95 | Tick p99 | Async-read p50 | Async-read p99 |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| Local-only control | 1.65 ms | 2.30 ms | 3.21 ms | 1.4 µs | 2.8 µs |
+| 1 peer writer, 50 writes/s | 2.24 ms | 3.33 ms | 4.80 ms | 1.6 µs | 6.5 µs |
+| 8 peer writers, 400 writes/s | 2.42 ms | 3.49 ms | 4.66 ms | 1.7 µs | 4.3 µs |
+
+At 400 remote writes/s, the modeled tick p99 consumes about 9.3% of a 50 ms tick budget on this host.
+
+The isolated four-peer measurements are:
+
+| Distributed operation | Mean | p50 | p95 | p99 |
+| --- | ---: | ---: | ---: | ---: |
+| PostgreSQL write/trigger/commit round trip | 1.13 ms | 1.07 ms | 1.72 ms | 2.22 ms |
+| Update request to visibility in the listening cache | 1.34 ms | 1.30 ms | 1.89 ms | 2.33 ms |
+
+These cover the distributed pipeline end to end. They do not isolate network delivery, listener work, H2 application, and cache invalidation into separate timings.
+
+## Controlled high-read load
+
+The controlled workload uses eight reader threads sharing one aggregate rate limiter. In parallel, one thread continuously scans all 100 players, eight peer sessions target a combined 400 writes/s, and eight PostgreSQL listeners receive invalidations.
+
+| Compound-read target | Achieved | Tick mean | Tick p95 | Tick p99 |
+| ---: | ---: | ---: | ---: | ---: |
+| 100,000/s | 98,568/s | 2.09 ms | 2.88 ms | 3.56 ms |
+| 250,000/s | 246,294/s | 2.17 ms | 3.13 ms | 4.33 ms |
+| 500,000/s | 496,101/s | 2.15 ms | 3.01 ms | 4.17 ms |
+
+The paced writers achieved about 400 updates/s and the seven notification-only peers observed about 2,800 callbacks/s, confirming the expected notification fan-out. No throughput cliff appeared by 500,000 compound reads/s on the measurement host.
+
+The JMH score for a controlled reader includes its wait for the next permit. Use the emitted achieved-rate line for controlled workloads and `ReadThroughputBenchmark` for saturation capacity.
+
+## Current slow paths
+
+1. `PersistentManyToManyCollectionImpl.getIds()` constructs SQL and executes an H2 join query for every collection read. This is the clearest measured read bottleneck. A membership cache needs dependency or generation invalidation that also handles remotely inserted join rows.
+2. `ReferenceImpl.getReferencedColumnValuePairs()` creates query inputs and identifier objects on cached reference reads. A cached per-reference lookup key could reduce allocation if it is invalidated when holder ID or linking columns change.
+3. A complete player scan repeatedly resolves relationships and collection members. Consumers that read the same projection several times during one tick may benefit from a server-layer per-tick snapshot.
+
+The benchmarks intentionally retain these production paths. Add a focused benchmark before optimizing one, then compare measurements on the same machine and commit range.
+
+## Scope and limitations
+
+The container-backed results use local Docker networking. Notification-only peers do not instantiate complete DataManager and H2 stacks. The suite does not model other plugins, Minecraft engine work, WAN latency, or database hosts under unrelated load. The continuously repeated tick scan is a contention stress workload rather than a 20 Hz scheduler.
diff --git a/benchmark/README.md b/benchmark/README.md
new file mode 100644
index 00000000..0bde16f5
--- /dev/null
+++ b/benchmark/README.md
@@ -0,0 +1,91 @@
+# Static Data benchmarks
+
+The benchmark module contains three layers:
+
+- `StaticDataBenchmark` is an integration benchmark backed by Testcontainers PostgreSQL and Redis plus Static Data's H2 cache. Its grouped workload models one Minecraft server thread resolving 32 players, settings references, and friend collections while four asynchronous workers resolve cached players.
+- `ReadThroughputBenchmark` reports explicit operations/second for individual production read paths and a complete configurable player scan. It defaults to eight reader threads, 100 retained players, and a fully warmed working set.
+- `CrossContainerLoadBenchmark` models one listening Static Data container and peer containers connected to the same PostgreSQL database. Peer writers use persistent database sessions with distinct application names, so writes traverse the real PostgreSQL trigger, `NOTIFY`, Static Data listener, H2 mirror update, cache invalidation, and subsequent local-read path. Additional notification-only listeners model PostgreSQL fan-out without incorrectly sharing a single H2 mirror between simulated containers.
+
+`CrossContainerLoadBenchmark` supplies these scenarios:
+
+- `localOnly`: one 100-player tick scan plus four local asynchronous readers, used as the control.
+- `readHeavyCrossContainer`: the same readers plus one peer writer, paced to 50 writes/s by default.
+- `writeHeavyCrossContainer`: the same readers plus eight peer writers, paced to a combined 400 writes/s by default.
+- `controlledReadLoad`: one tick scanner plus eight readers sharing an explicit aggregate target of 250,000 compound reads/s by default.
+- `controlledMixedLoad`: the controlled readers plus eight peer writers (400 writes/s total by default) and the configured notification listeners.
+- `remoteWriteRoundTrip`: four unpaced peer sessions measuring PostgreSQL update/trigger/commit latency.
+- `remoteUpdatePropagation`: four peer sessions measuring the complete update-request-to-local-cache-visibility latency.
+
+One compound read performs a player instance lookup, resolves its settings reference (including the settings instance lookup), and reads the settings priority and player name. Thus, 250,000 compound reads/s represents roughly one million public API-level lookup/value operations per second.
+
+The controlled-reader and mixed-load writer scores include intentional pacing and should not be interpreted as operation latency. The load-rate lines emitted after every iteration are the authoritative achieved read/write rates and also report notification fan-out plus Static Data's rolling H2 counters. Use `ReadThroughputBenchmark` for maximum read throughput and `remoteWriteRoundTrip` for database-write latency. The tick loop runs continuously rather than at 20 Hz, making it a contention stress test rather than a literal server scheduler.
+
+Docker must be running for the benchmark suite.
+
+Run the end-to-end Minecraft workload:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*StaticDataBenchmark.*'
+```
+
+Report maximum throughput for every production read path:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*ReadThroughputBenchmark.*'
+```
+
+Run the cross-container workloads:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*CrossContainerLoadBenchmark.*'
+```
+
+Run only the write-heavy scenario:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*CrossContainerLoadBenchmark.writeHeavy.*'
+```
+
+Run a controlled 100k/250k/500k compound-read matrix while eight peers write at a combined 400 writes/s:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*CrossContainerLoadBenchmark.controlledMixed.*' "-PjmhParams=targetReadsPerSecond=100000,250000,500000"
+```
+
+The container-backed states default to eight PostgreSQL notification listeners, 100 database players, a 100-player hot set, 100 players scanned per tick operation, eight friends per player, 50 writes/s per peer writer, peer writes partitioned across 100 players, and a fully retained/prewarmed read set. Each peer owns a disjoint slice so propagation tests cannot overwrite a value before its writer observes it. Override JMH parameters without editing source:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*CrossContainerLoadBenchmark.writeHeavy.*' "-PjmhParams=listenerCount=16;playerCount=1000;hotPlayerCount=500;playersPerTick=250;remoteWritePlayerCount=500;remoteWritesPerSecond=100"
+```
+
+Multiple values create a parameter matrix, for example `"-PjmhParams=listenerCount=1,4,8,16;remoteWritesPerSecond=10,50,100"`. `hotPlayerCount`, `playersPerTick`, and `remoteWritePlayerCount` must not exceed `playerCount`; `remoteWritePlayerCount` must also be at least the scenario's peer-writer count (eight for the heavy groups). A read or write rate of zero disables its pacing and runs it at saturation.
+
+To expose weak-cache misses and rehydration instead of measuring only a permanently hot online-player set, use a larger read set and disable full retention/prewarming:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*ReadThroughputBenchmark.compoundPlayerRead.*' "-PjmhParams=playerCount=1000;hotPlayerCount=1000;playersPerTick=100;warmReadWorkingSet=true,false"
+```
+
+Build a reader scaling curve by running the same command with `-PjmhThreads=1`, `4`, `8`, `16`, and `32`. The following optional project properties let CI or a local investigation make any scenario longer without editing annotations:
+
+- `jmhThreads`: override the benchmark's reader-thread count.
+- `jmhWarmupIterations`: number of warmup iterations.
+- `jmhIterations`: number of measured iterations.
+- `jmhTime`: duration of each iteration, such as `2s` or `60s`.
+- `jmhForks`: independent benchmark JVM count.
+
+For example, this is a five-minute measured soak at 500,000 compound reads/s plus 400 remote writes/s:
+
+```powershell
+.\gradlew.bat :benchmark:jmh -PjmhIncludes='.*CrossContainerLoadBenchmark.controlledMixed.*' "-PjmhParams=playerCount=1000;hotPlayerCount=1000;playersPerTick=250;targetReadsPerSecond=500000;warmReadWorkingSet=false" -PjmhWarmupIterations=2 -PjmhIterations=5 -PjmhTime=60s
+```
+
+Run every benchmark:
+
+```powershell
+.\gradlew.bat :benchmark:jmh
+```
+
+Machine-readable results are written to `benchmark/build/reports/jmh/results.json`; the complete console-style report is written to `benchmark/build/reports/jmh/human.txt`. Each invocation replaces these files, so copy them elsewhere when comparing separate runs.
+
+See [`PERFORMANCE.md`](PERFORMANCE.md) for the current baseline, measured slow paths, and benchmark limitations.
diff --git a/benchmark/build.gradle b/benchmark/build.gradle
index ca884342..87d56ec4 100644
--- a/benchmark/build.gradle
+++ b/benchmark/build.gradle
@@ -22,6 +22,7 @@ dependencies {
implementation 'net.staticstudios:static-utils:1.0.6-SNAPSHOT'
implementation("org.testcontainers:postgresql:1.19.8")
implementation("com.redis:testcontainers-redis:2.2.2")
+ implementation("com.impossibl.pgjdbc-ng:pgjdbc-ng:0.8.9")
implementation("org.slf4j:slf4j-log4j12:2.0.16")
}
@@ -33,15 +34,67 @@ tasks.named('jmh') {
jvmArgs = [
'-Xms1g',
'-Xmx1g',
- '-XX:+AlwaysPreTouch',
- '-Djmh.ignoreLock=true'
+ '-XX:+AlwaysPreTouch'
]
}
+jmh {
+ def configuredIncludes = project.findProperty('jmhIncludes')
+ if (configuredIncludes != null) {
+ includes = [configuredIncludes.toString()]
+ }
+
+ def configuredParams = project.findProperty('jmhParams')
+ if (configuredParams != null) {
+ benchmarkParameters = configuredParams.toString()
+ .split(';')
+ .collectEntries { entry ->
+ def parts = entry.split('=', 2)
+ if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
+ throw new GradleException("Invalid jmhParams entry '${entry}'; expected name=value1,value2")
+ }
+ def values = project.objects.listProperty(String)
+ values.set(parts[1].split(',').toList())
+ [(parts[0]): values]
+ }
+ }
+
+ def configuredThreads = project.findProperty('jmhThreads')
+ if (configuredThreads != null) {
+ threads = Integer.parseInt(configuredThreads.toString())
+ }
+
+ def configuredIterations = project.findProperty('jmhIterations')
+ if (configuredIterations != null) {
+ iterations = Integer.parseInt(configuredIterations.toString())
+ }
+
+ def configuredWarmupIterations = project.findProperty('jmhWarmupIterations')
+ if (configuredWarmupIterations != null) {
+ warmupIterations = Integer.parseInt(configuredWarmupIterations.toString())
+ }
+
+ def configuredIterationTime = project.findProperty('jmhTime')
+ if (configuredIterationTime != null) {
+ timeOnIteration = configuredIterationTime.toString()
+ }
+
+ def configuredForks = project.findProperty('jmhForks')
+ if (configuredForks != null) {
+ fork = Integer.parseInt(configuredForks.toString())
+ }
+
+ failOnError = true
+ forceGC = true
+ resultFormat = 'JSON'
+ resultsFile = layout.buildDirectory.file('reports/jmh/results.json').get().asFile
+ humanOutputFile = layout.buildDirectory.file('reports/jmh/human.txt').get().asFile
+}
+
java {
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_21
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
-}
\ No newline at end of file
+}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/CrossContainerLoadBenchmark.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/CrossContainerLoadBenchmark.java
new file mode 100644
index 00000000..89f9fd2c
--- /dev/null
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/CrossContainerLoadBenchmark.java
@@ -0,0 +1,142 @@
+package net.staticstudios.data.benchmark;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Group;
+import org.openjdk.jmh.annotations.GroupThreads;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Exercises one Static Data container while writes from other containers are
+ * committed to the same PostgreSQL database.
+ *
+ *
The remote writers use persistent PostgreSQL sessions with distinct
+ * application names. Consequently every write follows the production path:
+ * PostgreSQL trigger, NOTIFY, Static Data listener, H2 mirror update, H2
+ * invalidation trigger, and the next local read.
+ */
+@BenchmarkMode(Mode.SampleTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+public class CrossContainerLoadBenchmark {
+
+ @Benchmark
+ @Group("localOnly")
+ @GroupThreads(1)
+ public long localOnlyMinecraftTick(CrossContainerLoadState state) {
+ return state.runMinecraftTick();
+ }
+
+ @Benchmark
+ @Group("localOnly")
+ @GroupThreads(4)
+ public long localOnlyAsyncPlayerRead(CrossContainerLoadState state) {
+ return state.readRandomPlayer();
+ }
+
+ @Benchmark
+ @Group("readHeavyCrossContainer")
+ @GroupThreads(1)
+ public long readHeavyMinecraftTick(CrossContainerLoadState state) {
+ return state.runMinecraftTick();
+ }
+
+ @Benchmark
+ @Group("readHeavyCrossContainer")
+ @GroupThreads(4)
+ public long readHeavyAsyncPlayerRead(CrossContainerLoadState state) {
+ return state.readRandomPlayer();
+ }
+
+ @Benchmark
+ @Group("readHeavyCrossContainer")
+ @GroupThreads(1)
+ public long readHeavyRemoteWriter(CrossContainerLoadState state) {
+ return state.writeFromRemoteContainer(true, 1).sequence();
+ }
+
+ @Benchmark
+ @Group("writeHeavyCrossContainer")
+ @GroupThreads(1)
+ public long writeHeavyMinecraftTick(CrossContainerLoadState state) {
+ return state.runMinecraftTick();
+ }
+
+ @Benchmark
+ @Group("writeHeavyCrossContainer")
+ @GroupThreads(4)
+ public long writeHeavyAsyncPlayerRead(CrossContainerLoadState state) {
+ return state.readRandomPlayer();
+ }
+
+ @Benchmark
+ @Group("writeHeavyCrossContainer")
+ @GroupThreads(8)
+ public long writeHeavyRemoteWriter(CrossContainerLoadState state) {
+ return state.writeFromRemoteContainer(true, 8).sequence();
+ }
+
+ @Benchmark
+ @Group("controlledReadLoad")
+ @GroupThreads(1)
+ public long controlledReadLoadMinecraftTick(CrossContainerLoadState state) {
+ return state.runMinecraftTick();
+ }
+
+ @Benchmark
+ @Group("controlledReadLoad")
+ @GroupThreads(8)
+ public long controlledReadLoadReader(CrossContainerLoadState state) {
+ return state.readRandomPlayerAtControlledRate();
+ }
+
+ @Benchmark
+ @Group("controlledMixedLoad")
+ @GroupThreads(1)
+ public long controlledMixedLoadMinecraftTick(CrossContainerLoadState state) {
+ return state.runMinecraftTick();
+ }
+
+ @Benchmark
+ @Group("controlledMixedLoad")
+ @GroupThreads(8)
+ public long controlledMixedLoadReader(CrossContainerLoadState state) {
+ return state.readRandomPlayerAtControlledRate();
+ }
+
+ @Benchmark
+ @Group("controlledMixedLoad")
+ @GroupThreads(8)
+ public long controlledMixedLoadRemoteWriter(CrossContainerLoadState state) {
+ return state.writeFromRemoteContainer(true, 8).sequence();
+ }
+
+ /** Measures the persistent-database write latency from four peer containers. */
+ @Benchmark
+ @Group("remoteWriteRoundTrip")
+ @GroupThreads(4)
+ public long remoteWriteRoundTrip(CrossContainerLoadState state) {
+ return state.writeFromRemoteContainer(false, 4).sequence();
+ }
+
+ /**
+ * Measures request-to-visibility latency with four independent remote writers.
+ * The operation completes only after the listening Static Data instance reads
+ * the newly committed value from its invalidated local cache.
+ */
+ @Benchmark
+ @Group("remoteUpdatePropagation")
+ @GroupThreads(4)
+ public long remoteUpdatePropagation(CrossContainerLoadState state) {
+ CrossContainerLoadState.RemoteWrite write = state.writeFromRemoteContainer(false, 4);
+ return state.awaitRemoteWrite(write);
+ }
+}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/CrossContainerLoadState.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/CrossContainerLoadState.java
new file mode 100644
index 00000000..c5aa6436
--- /dev/null
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/CrossContainerLoadState.java
@@ -0,0 +1,410 @@
+package net.staticstudios.data.benchmark;
+
+import com.impossibl.postgres.api.jdbc.PGConnection;
+import com.impossibl.postgres.api.jdbc.PGNotificationListener;
+import net.staticstudios.data.StaticDataStatistics;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.LongAdder;
+import java.util.concurrent.locks.LockSupport;
+
+/**
+ * Adds peer writers and PostgreSQL notification listeners to the shared player
+ * workload used by the distributed-load benchmarks.
+ */
+@State(Scope.Group)
+public class CrossContainerLoadState extends PlayerWorkloadState {
+ private static final long PROPAGATION_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(10);
+
+ /** Target rate for each remote writer; zero runs writers at saturation. */
+ @Param({"50"})
+ public int remoteWritesPerSecond;
+
+ /** Rows partitioned between the peer writers. */
+ @Param({"100"})
+ public int remoteWritePlayerCount;
+
+ /** PostgreSQL listeners, including the full Static Data listener. */
+ @Param({"8"})
+ public int listenerCount;
+
+ /** Aggregate compound reads/s in controlled-load groups; zero means saturation. */
+ @Param({"250000"})
+ public int targetReadsPerSecond;
+
+ private final AtomicInteger nextWriterIndex = new AtomicInteger();
+ private final AtomicLong nextReadPermitNanos = new AtomicLong();
+ private final List writerSessions = new CopyOnWriteArrayList<>();
+ private final List peerListeners = new CopyOnWriteArrayList<>();
+ private final LongAdder peerNotifications = new LongAdder();
+ private final LongAdder completedControlledReads = new LongAdder();
+ private final LongAdder completedPacedRemoteWrites = new LongAdder();
+ private final ThreadLocal writerSession = new ThreadLocal<>();
+ private volatile boolean controlledReadsActive;
+ private volatile boolean pacedRemoteWritesActive;
+ private volatile long loadIterationStartNanos;
+
+ @Override
+ protected void afterSetup() throws Exception {
+ validateDistributedParameters();
+ openPeerListeners();
+ }
+
+ private void validateDistributedParameters() {
+ if (remoteWritesPerSecond < 0) {
+ throw new IllegalArgumentException("remoteWritesPerSecond cannot be negative");
+ }
+ if (remoteWritePlayerCount <= 0 || remoteWritePlayerCount > playerCount) {
+ throw new IllegalArgumentException("remoteWritePlayerCount must be between 1 and playerCount");
+ }
+ if (listenerCount <= 0) {
+ throw new IllegalArgumentException("listenerCount must be positive");
+ }
+ if (targetReadsPerSecond < 0) {
+ throw new IllegalArgumentException("targetReadsPerSecond cannot be negative");
+ }
+ }
+
+ private void openPeerListeners() throws SQLException {
+ for (int i = 1; i < listenerCount; i++) {
+ Properties properties = connectionProperties("static-data-benchmark-listener-");
+ PGConnection connection = DriverManager.getConnection(postgresJdbcUrl(), properties)
+ .unwrap(PGConnection.class);
+ connection.addNotificationListener(
+ "static-data-benchmark-peer-" + i,
+ "data_notification_v3",
+ new PGNotificationListener() {
+ @Override
+ public void notification(int processId, String channelName, String payload) {
+ peerNotifications.increment();
+ }
+ }
+ );
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("LISTEN data_notification_v3");
+ }
+ peerListeners.add(connection);
+ }
+ }
+
+ @Setup(Level.Iteration)
+ public void setupIteration() {
+ completedControlledReads.reset();
+ completedPacedRemoteWrites.reset();
+ peerNotifications.reset();
+ controlledReadsActive = false;
+ pacedRemoteWritesActive = false;
+ loadIterationStartNanos = System.nanoTime();
+ nextReadPermitNanos.set(loadIterationStartNanos);
+ }
+
+ @TearDown(Level.Iteration)
+ public void reportLoadRates() {
+ if (!controlledReadsActive && !pacedRemoteWritesActive) {
+ return;
+ }
+
+ long elapsedNanos = System.nanoTime() - loadIterationStartNanos;
+ StaticDataStatistics statistics = dataManager().getStatistics();
+ if (controlledReadsActive) {
+ System.out.printf(
+ "Controlled compound reads: target=%d/s, achieved=%.0f/s%n",
+ targetReadsPerSecond,
+ ratePerSecond(completedControlledReads.sum(), elapsedNanos)
+ );
+ }
+ if (pacedRemoteWritesActive) {
+ System.out.printf(
+ "Paced remote writes: per-peer target=%d/s, achieved aggregate=%.0f/s, " +
+ "notification-only peers=%d, observed fan-out=%.0f notifications/s%n",
+ remoteWritesPerSecond,
+ ratePerSecond(completedPacedRemoteWrites.sum(), elapsedNanos),
+ listenerCount - 1,
+ ratePerSecond(peerNotifications.sum(), elapsedNanos)
+ );
+ }
+ System.out.printf(
+ "Static Data rolling counters: H2 queries=%d/s, H2 updates=%d/s%n",
+ statistics.getQueriesPerSecond(),
+ statistics.getUpdatesPerSecond()
+ );
+ }
+
+ private double ratePerSecond(long completed, long elapsedNanos) {
+ return completed * (double) TimeUnit.SECONDS.toNanos(1) / elapsedNanos;
+ }
+
+ public long readRandomPlayerAtControlledRate() {
+ awaitReadPermit();
+ long result = readRandomPlayer();
+ controlledReadsActive = true;
+ completedControlledReads.increment();
+ return result;
+ }
+
+ private void awaitReadPermit() {
+ if (targetReadsPerSecond == 0) {
+ return;
+ }
+
+ long intervalNanos = Math.max(1, TimeUnit.SECONDS.toNanos(1) / targetReadsPerSecond);
+ long permitNanos = nextReadPermitNanos.getAndAdd(intervalNanos);
+ while (true) {
+ long remainingNanos = permitNanos - System.nanoTime();
+ if (remainingNanos <= 0) {
+ return;
+ }
+ if (remainingNanos > 5_000) {
+ Thread.yield();
+ } else {
+ Thread.onSpinWait();
+ }
+ }
+ }
+
+ public RemoteWrite writeFromRemoteContainer(boolean paced, int concurrentWriters) {
+ RemoteWriterSession session = writerSession.get();
+ if (session == null) {
+ session = openWriterSession(concurrentWriters);
+ writerSession.set(session);
+ } else if (session.concurrentWriters != concurrentWriters) {
+ throw new IllegalStateException("Remote-writer concurrency changed within one trial");
+ }
+
+ if (paced) {
+ session.awaitWritePermit(remoteWritesPerSecond);
+ }
+ RemoteWrite write = session.writeNext();
+ if (paced) {
+ pacedRemoteWritesActive = true;
+ completedPacedRemoteWrites.increment();
+ }
+ return write;
+ }
+
+ private RemoteWriterSession openWriterSession(int concurrentWriters) {
+ if (concurrentWriters <= 0 || concurrentWriters > remoteWritePlayerCount) {
+ throw new IllegalArgumentException(
+ "concurrentWriters must be between 1 and remoteWritePlayerCount"
+ );
+ }
+
+ int writerIndex = nextWriterIndex.getAndIncrement();
+ if (writerIndex >= concurrentWriters) {
+ throw new IllegalStateException(
+ "JMH created more remote-writer threads than the benchmark declared"
+ );
+ }
+
+ try {
+ Connection connection = DriverManager.getConnection(
+ postgresJdbcUrl(),
+ connectionProperties("static-data-benchmark-remote-")
+ );
+ RemoteWriterSession session = new RemoteWriterSession(
+ connection,
+ writerIndex,
+ concurrentWriters
+ );
+ writerSessions.add(session);
+ return session;
+ } catch (SQLException e) {
+ throw new RuntimeException("Unable to open a simulated remote-container connection", e);
+ }
+ }
+
+ public long awaitRemoteWrite(RemoteWrite write) {
+ long start = System.nanoTime();
+ long deadline = start + PROPAGATION_TIMEOUT_NANOS;
+ int attempts = 0;
+
+ while (!isVisible(write)) {
+ if (System.nanoTime() >= deadline) {
+ throw new RuntimeException(new TimeoutException(
+ "Remote write was not visible to the listening Static Data instance within 10 seconds"
+ ));
+ }
+
+ if (attempts++ < 1_000) {
+ Thread.onSpinWait();
+ } else {
+ Thread.yield();
+ }
+ }
+
+ return System.nanoTime() - start;
+ }
+
+ private boolean isVisible(RemoteWrite write) {
+ if (write.playerName()) {
+ return Objects.equals(write.name(), playerAt(write.playerIndex()).name.get());
+ }
+ return write.priority() == settingsAt(write.playerIndex()).tablistPriority.get();
+ }
+
+ private Properties connectionProperties(String applicationNamePrefix) {
+ Properties properties = new Properties();
+ properties.setProperty("user", postgres().getUsername());
+ properties.setProperty("password", postgres().getPassword());
+ properties.setProperty("application.name", applicationNamePrefix + UUID.randomUUID());
+ return properties;
+ }
+
+ private String postgresJdbcUrl() {
+ return "jdbc:pgsql://" + postgres().getHost() + ':' +
+ postgres().getFirstMappedPort() + '/' + postgres().getDatabaseName();
+ }
+
+ @Override
+ protected void beforeTearDown() {
+ RuntimeException closeFailure = null;
+
+ for (RemoteWriterSession session : writerSessions) {
+ try {
+ session.close();
+ } catch (RuntimeException e) {
+ closeFailure = accumulate(closeFailure, e);
+ }
+ }
+ writerSession.remove();
+
+ for (PGConnection peerListener : peerListeners) {
+ try {
+ peerListener.close();
+ } catch (SQLException e) {
+ closeFailure = accumulate(
+ closeFailure,
+ new RuntimeException("Unable to close a simulated peer listener", e)
+ );
+ }
+ }
+
+ if (closeFailure != null) {
+ throw closeFailure;
+ }
+ }
+
+ private RuntimeException accumulate(RuntimeException current, RuntimeException next) {
+ if (current == null) {
+ return next;
+ }
+ current.addSuppressed(next);
+ return current;
+ }
+
+ public record RemoteWrite(
+ int playerIndex,
+ boolean playerName,
+ String name,
+ int priority,
+ long sequence
+ ) {
+ }
+
+ private final class RemoteWriterSession implements AutoCloseable {
+ private final int concurrentWriters;
+ private final int firstPlayerIndex;
+ private final int playerCount;
+ private final Connection connection;
+ private final PreparedStatement updatePlayerName;
+ private final PreparedStatement updateSettingsPriority;
+ private long sequence;
+ private long nextWriteNanos;
+
+ private RemoteWriterSession(
+ Connection connection,
+ int writerIndex,
+ int concurrentWriters
+ ) throws SQLException {
+ this.connection = connection;
+ this.concurrentWriters = concurrentWriters;
+ this.firstPlayerIndex = remoteWritePlayerCount * writerIndex / concurrentWriters;
+ int nextPartitionStart = remoteWritePlayerCount * (writerIndex + 1) / concurrentWriters;
+ this.playerCount = nextPartitionStart - firstPlayerIndex;
+ this.updatePlayerName = connection.prepareStatement(
+ "UPDATE \"skyblock\".\"players\" SET \"name\" = ? WHERE \"id\" = ?"
+ );
+ this.updateSettingsPriority = connection.prepareStatement(
+ "UPDATE \"skyblock\".\"player_settings\" SET \"tablist_priority\" = ? WHERE \"id\" = ?"
+ );
+ }
+
+ private void awaitWritePermit(int writesPerSecond) {
+ if (writesPerSecond == 0) {
+ return;
+ }
+
+ long interval = TimeUnit.SECONDS.toNanos(1) / writesPerSecond;
+ long now = System.nanoTime();
+ if (nextWriteNanos == 0 || now - nextWriteNanos > interval) {
+ nextWriteNanos = now;
+ }
+
+ long waitNanos = nextWriteNanos - now;
+ if (waitNanos > 0) {
+ LockSupport.parkNanos(waitNanos);
+ }
+ nextWriteNanos += interval;
+ }
+
+ private RemoteWrite writeNext() {
+ long currentSequence = ++sequence;
+ boolean playerName = (currentSequence & 1) == 1;
+ int playerIndex = firstPlayerIndex + Math.floorMod(currentSequence - 1, playerCount);
+
+ try {
+ if (playerName) {
+ String name = "RemotePlayer-" + playerIndex + '-' + currentSequence;
+ updatePlayerName.setString(1, name);
+ updatePlayerName.setObject(2, playerIdAt(playerIndex));
+ requireOneUpdatedRow(updatePlayerName.executeUpdate());
+ return new RemoteWrite(playerIndex, true, name, 0, currentSequence);
+ }
+
+ int priority = Math.toIntExact(currentSequence);
+ updateSettingsPriority.setInt(1, priority);
+ updateSettingsPriority.setObject(2, settingsIdAt(playerIndex));
+ requireOneUpdatedRow(updateSettingsPriority.executeUpdate());
+ return new RemoteWrite(playerIndex, false, null, priority, currentSequence);
+ } catch (SQLException e) {
+ throw new RuntimeException("Remote-container write failed", e);
+ }
+ }
+
+ private void requireOneUpdatedRow(int updatedRows) {
+ if (updatedRows != 1) {
+ throw new IllegalStateException("Expected one updated row, got " + updatedRows);
+ }
+ }
+
+ @Override
+ public void close() {
+ try {
+ updatePlayerName.close();
+ updateSettingsPriority.close();
+ connection.close();
+ } catch (SQLException e) {
+ throw new RuntimeException("Unable to close a simulated remote-container connection", e);
+ }
+ }
+ }
+}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/PlayerWorkloadState.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/PlayerWorkloadState.java
new file mode 100644
index 00000000..134f4ba8
--- /dev/null
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/PlayerWorkloadState.java
@@ -0,0 +1,284 @@
+package net.staticstudios.data.benchmark;
+
+import com.redis.testcontainers.RedisContainer;
+import net.staticstudios.data.DataManager;
+import net.staticstudios.data.InsertMode;
+import net.staticstudios.data.StaticDataConfig;
+import net.staticstudios.data.benchmark.data.SkyblockPlayer;
+import net.staticstudios.data.benchmark.data.SkyblockPlayerSettings;
+import net.staticstudios.data.insert.BatchInsert;
+import net.staticstudios.data.util.ColumnValuePair;
+import net.staticstudios.utils.ThreadUtilProvider;
+import net.staticstudios.utils.ThreadUtils;
+import org.openjdk.jmh.annotations.Level;
+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.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Owns the container-backed player dataset and read operations shared by the
+ * throughput and distributed-load benchmarks.
+ */
+@State(Scope.Benchmark)
+public abstract class PlayerWorkloadState {
+ private static final int FRIENDS_PER_PLAYER = 8;
+
+ /** Total rows in the benchmark dataset. */
+ @Param({"100"})
+ public int playerCount;
+
+ /** Player working set used by round-robin read operations. */
+ @Param({"100"})
+ public int hotPlayerCount;
+
+ /** Players scanned by one simulated server-tick operation. */
+ @Param({"100"})
+ public int playersPerTick;
+
+ /** Retain and prewarm the complete random-read working set. */
+ @Param({"true"})
+ public boolean warmReadWorkingSet;
+
+ private RedisContainer redis;
+ private PostgreSQLContainer> postgres;
+ private DataManager dataManager;
+ private List playerIds;
+ private List settingsIds;
+ private List retainedPlayers;
+ private List retainedSettings;
+ private final AtomicInteger nextPlayerIndex = new AtomicInteger();
+
+ @Setup(Level.Trial)
+ public void setup() throws Exception {
+ validateParameters();
+
+ redis = new RedisContainer(DockerImageName.parse("redis:7.4.1"));
+ redis.start();
+ redis.execInContainer("redis-cli", "config", "set", "notify-keyspace-events", "KEA");
+
+ postgres = new PostgreSQLContainer<>("postgres:16.2")
+ .withPassword("password")
+ .withUsername("postgres")
+ .withDatabaseName("postgres");
+ postgres.start();
+
+ ThreadUtils.setProvider(ThreadUtilProvider.builder().build());
+ StaticDataConfig config = new StaticDataConfig(
+ postgres.getHost(),
+ postgres.getFirstMappedPort(),
+ postgres.getDatabaseName(),
+ postgres.getUsername(),
+ postgres.getPassword(),
+ redis.getHost(),
+ redis.getRedisPort(),
+ Runnable::run
+ );
+
+ dataManager = new DataManager(config, false);
+ dataManager.load(SkyblockPlayer.class);
+ dataManager.finishLoading();
+
+ seedPlayers();
+ warmLocalCaches();
+ afterSetup();
+ }
+
+ /** Hook for specialized workloads that need resources after the dataset is ready. */
+ protected void afterSetup() throws Exception {
+ }
+
+ private void validateParameters() {
+ if (playerCount <= 0) {
+ throw new IllegalArgumentException("playerCount must be positive");
+ }
+ if (hotPlayerCount <= 0 || hotPlayerCount > playerCount) {
+ throw new IllegalArgumentException("hotPlayerCount must be between 1 and playerCount");
+ }
+ if (playersPerTick <= 0 || playersPerTick > playerCount) {
+ throw new IllegalArgumentException("playersPerTick must be between 1 and playerCount");
+ }
+ }
+
+ private void seedPlayers() {
+ List mutablePlayerIds = new ArrayList<>(playerCount);
+ List mutableSettingsIds = new ArrayList<>(playerCount);
+ List> playerFutures = new ArrayList<>(playerCount);
+ List> settingsFutures = new ArrayList<>(playerCount);
+ BatchInsert batch = dataManager.createBatchInsert();
+
+ for (int i = 0; i < playerCount; i++) {
+ UUID settingsId = UUID.randomUUID();
+ UUID playerId = UUID.randomUUID();
+
+ settingsFutures.add(SkyblockPlayerSettings.builder(dataManager)
+ .id(settingsId)
+ .tablistPriority(i)
+ .insert(batch));
+ playerFutures.add(SkyblockPlayer.builder(dataManager)
+ .id(playerId)
+ .name("FakePlayer" + i)
+ .settingsId(settingsId)
+ .insert(batch));
+
+ mutableSettingsIds.add(settingsId);
+ mutablePlayerIds.add(playerId);
+ }
+
+ batch.insert(InsertMode.SYNC);
+
+ List allPlayers = playerFutures.stream().map(CompletableFuture::join).toList();
+ List allSettings = settingsFutures.stream().map(CompletableFuture::join).toList();
+
+ int friendsPerPlayer = Math.min(FRIENDS_PER_PLAYER, playerCount - 1);
+ for (int playerIndex = 0; playerIndex < allPlayers.size(); playerIndex++) {
+ List friends = new ArrayList<>(friendsPerPlayer);
+ for (int offset = 1; offset <= friendsPerPlayer; offset++) {
+ friends.add(allPlayers.get((playerIndex + offset) % allPlayers.size()));
+ }
+ allPlayers.get(playerIndex).friends.addAll(friends);
+ }
+
+ dataManager.flushTaskQueue();
+ playerIds = Collections.unmodifiableList(mutablePlayerIds);
+ settingsIds = Collections.unmodifiableList(mutableSettingsIds);
+
+ int retainedPlayerCount = Math.min(
+ playerCount,
+ Math.max(32, Math.max(playersPerTick, warmReadWorkingSet ? hotPlayerCount : 0))
+ );
+ retainedPlayers = Collections.unmodifiableList(
+ new ArrayList<>(allPlayers.subList(0, retainedPlayerCount))
+ );
+ retainedSettings = Collections.unmodifiableList(
+ new ArrayList<>(allSettings.subList(0, retainedPlayerCount))
+ );
+ }
+
+ private void warmLocalCaches() {
+ int playersToWarm = Math.max(playersPerTick, warmReadWorkingSet ? hotPlayerCount : 0);
+ for (int i = 0; i < playersToWarm; i++) {
+ SkyblockPlayer player = playerAt(i);
+ player.name.get();
+ player.settings.get();
+ settingsAt(i).tablistPriority.get();
+ if (i < playersPerTick) {
+ player.friends.forEach(friend -> friend.name.get());
+ }
+ }
+ }
+
+ public long runMinecraftTick() {
+ long checksum = 0;
+
+ for (int i = 0; i < playersPerTick; i++) {
+ SkyblockPlayer player = playerAt(i);
+ checksum += player.name.get().hashCode();
+
+ SkyblockPlayerSettings playerSettings = player.settings.get();
+ if (playerSettings != null) {
+ checksum += playerSettings.tablistPriority.get();
+ }
+ for (SkyblockPlayer friend : player.friends) {
+ checksum += friend.name.get().hashCode();
+ }
+ }
+
+ return checksum;
+ }
+
+ public long readRandomPlayer() {
+ SkyblockPlayer player = playerAt(nextReadPlayerIndex());
+ SkyblockPlayerSettings playerSettings = player.settings.get();
+ int priority = playerSettings == null ? 0 : playerSettings.tablistPriority.get();
+ return 31L * player.name.get().hashCode() + priority;
+ }
+
+ public SkyblockPlayer readInstance() {
+ return playerAt(nextReadPlayerIndex());
+ }
+
+ public long readPersistentValue() {
+ return playerAt(nextReadPlayerIndex()).name.get().hashCode();
+ }
+
+ public long readReference() {
+ SkyblockPlayerSettings playerSettings = playerAt(nextReadPlayerIndex()).settings.get();
+ return playerSettings == null ? 0 : playerSettings.getIdColumns().hashCode();
+ }
+
+ public long readFriendCollection() {
+ long checksum = 0;
+ for (SkyblockPlayer friend : playerAt(nextReadPlayerIndex()).friends) {
+ checksum += friend.getIdColumns().hashCode();
+ }
+ return checksum;
+ }
+
+ private int nextReadPlayerIndex() {
+ return Math.floorMod(nextPlayerIndex.getAndIncrement(), hotPlayerCount);
+ }
+
+ protected final SkyblockPlayer playerAt(int index) {
+ return dataManager.getInstance(
+ SkyblockPlayer.class,
+ new ColumnValuePair("id", playerIds.get(index))
+ );
+ }
+
+ protected final SkyblockPlayerSettings settingsAt(int index) {
+ return dataManager.getInstance(
+ SkyblockPlayerSettings.class,
+ new ColumnValuePair("id", settingsIds.get(index))
+ );
+ }
+
+ protected final UUID playerIdAt(int index) {
+ return playerIds.get(index);
+ }
+
+ protected final UUID settingsIdAt(int index) {
+ return settingsIds.get(index);
+ }
+
+ protected final DataManager dataManager() {
+ return dataManager;
+ }
+
+ protected final PostgreSQLContainer> postgres() {
+ return postgres;
+ }
+
+ @TearDown(Level.Trial)
+ public void tearDown() {
+ try {
+ beforeTearDown();
+ if (dataManager != null) {
+ dataManager.flushTaskQueue();
+ }
+ } finally {
+ ThreadUtils.shutdown();
+
+ if (postgres != null) {
+ postgres.stop();
+ }
+ if (redis != null) {
+ redis.stop();
+ }
+ }
+ }
+
+ /** Hook for specialized workloads that must close resources before the containers stop. */
+ protected void beforeTearDown() {
+ }
+}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/ReadThroughputBenchmark.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/ReadThroughputBenchmark.java
new file mode 100644
index 00000000..4c667942
--- /dev/null
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/ReadThroughputBenchmark.java
@@ -0,0 +1,63 @@
+package net.staticstudios.data.benchmark;
+
+import net.staticstudios.data.benchmark.data.SkyblockPlayer;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Reports achieved operations per second for production read paths. Each
+ * benchmark defaults to eight concurrent reader threads; use -PjmhThreads to
+ * build a 1/4/8/16/32-thread scaling curve.
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Threads(8)
+@Fork(1)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
+public class ReadThroughputBenchmark {
+
+ /** One real DataManager instance-cache lookup, including identifier-key construction. */
+ @Benchmark
+ public SkyblockPlayer instanceCacheHit(ReadThroughputState state) {
+ return state.readInstance();
+ }
+
+ /** One player lookup followed by one PersistentValue read. */
+ @Benchmark
+ public long persistentValueRead(ReadThroughputState state) {
+ return state.readPersistentValue();
+ }
+
+ /** One player lookup followed by settings-reference resolution. */
+ @Benchmark
+ public long referenceRead(ReadThroughputState state) {
+ return state.readReference();
+ }
+
+ /** One player lookup and one uncached many-to-many H2 membership query. */
+ @Benchmark
+ public long friendCollectionRead(ReadThroughputState state) {
+ return state.readFriendCollection();
+ }
+
+ /** Player lookup, settings reference, priority value, and player-name value. */
+ @Benchmark
+ public long compoundPlayerRead(ReadThroughputState state) {
+ return state.readRandomPlayer();
+ }
+
+ /** A full configurable online-player scan, including settings and friends. */
+ @Benchmark
+ public long completePlayerScan(ReadThroughputState state) {
+ return state.runMinecraftTick();
+ }
+}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/ReadThroughputState.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/ReadThroughputState.java
new file mode 100644
index 00000000..592a23ca
--- /dev/null
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/ReadThroughputState.java
@@ -0,0 +1,9 @@
+package net.staticstudios.data.benchmark;
+
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.State;
+
+/** Shares one Static Data environment across all readers in a throughput trial. */
+@State(Scope.Benchmark)
+public class ReadThroughputState extends PlayerWorkloadState {
+}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmark.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmark.java
index cc4cfacc..8b4ec612 100644
--- a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmark.java
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmark.java
@@ -1,44 +1,81 @@
package net.staticstudios.data.benchmark;
-import net.staticstudios.data.InsertMode;
import net.staticstudios.data.benchmark.data.SkyblockPlayer;
-import org.openjdk.jmh.annotations.*;
+import net.staticstudios.data.benchmark.data.SkyblockPlayerSettings;
+import net.staticstudios.data.util.ColumnValuePair;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Group;
+import org.openjdk.jmh.annotations.GroupThreads;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Warmup;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
+/**
+ * End-to-end benchmarks backed by the same H2/PostgreSQL/Redis stack used in production.
+ *
+ * The grouped benchmark models one Minecraft server thread scanning all online players
+ * while four worker threads repeatedly resolve cached entities. This is the contention
+ * pattern visible in the supplied spark profile.
+ */
@BenchmarkMode(Mode.AverageTime)
-@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Fork(1)
-@Warmup(iterations = 3)
-@Measurement(iterations = 10)
+@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public class StaticDataBenchmark {
-// @Benchmark
-// public void sampleBenchmark(StaticDataBenchmarkState state) {
-// // Sample benchmark method
-// int sum = 0;
-// for (int i = 0; i < 1000; i++) {
-// sum += i;
-// }
-// }
-
-// @Benchmark
-// public void testPersistentValueRead(StaticDataBenchmarkState state) {
-//
-// }
+ @Benchmark
+ @Group("hotInstanceCacheHit")
+ @GroupThreads(1)
+ public SkyblockPlayer hotInstanceCacheHit(StaticDataBenchmarkState state) {
+ UUID id = state.nextPlayerId();
+ return state.dataManager().getInstance(
+ SkyblockPlayer.class,
+ new ColumnValuePair("id", id)
+ );
+ }
@Benchmark
- public void testUniqueDataInsertAsync(StaticDataBenchmarkState state) {
- for (int i = 0; i < 100; i++) {
- SkyblockPlayer player = SkyblockPlayer.builder()
- .id(UUID.randomUUID())
- .name("Player" + i)
- .insert(InsertMode.ASYNC); //todo: this seems broken, the bench takes oddly long.
+ @Group("minecraftTickUnderAsyncLoad")
+ @GroupThreads(1)
+ public long minecraftServerThreadTick(StaticDataBenchmarkState state) {
+ long checksum = 0;
+
+ for (UUID playerId : state.playerIds()) {
+ SkyblockPlayer player = state.dataManager().getInstance(
+ SkyblockPlayer.class,
+ new ColumnValuePair("id", playerId)
+ );
+
+ checksum += player.name.get().hashCode();
+
+ SkyblockPlayerSettings settings = player.settings.get();
+ if (settings != null) {
+ checksum += settings.tablistPriority.get();
+ }
+
+ for (SkyblockPlayer friend : player.friends) {
+ checksum += friend.name.get().hashCode();
+ }
}
+
+ return checksum;
}
-// @Benchmark
-// public void testPersistentValueWrite(StaticDataBenchmarkState state) {
-// }
+ @Benchmark
+ @Group("minecraftTickUnderAsyncLoad")
+ @GroupThreads(4)
+ public SkyblockPlayer asyncCacheReader(StaticDataBenchmarkState state) {
+ UUID id = state.nextPlayerId();
+ return state.dataManager().getInstance(
+ SkyblockPlayer.class,
+ new ColumnValuePair("id", id)
+ );
+ }
}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmarkState.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmarkState.java
index e59512ef..197d490f 100644
--- a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmarkState.java
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/StaticDataBenchmarkState.java
@@ -2,60 +2,132 @@
import com.redis.testcontainers.RedisContainer;
import net.staticstudios.data.DataManager;
+import net.staticstudios.data.InsertMode;
import net.staticstudios.data.StaticDataConfig;
import net.staticstudios.data.benchmark.data.SkyblockPlayer;
+import net.staticstudios.data.benchmark.data.SkyblockPlayerSettings;
import net.staticstudios.utils.ThreadUtilProvider;
import net.staticstudios.utils.ThreadUtils;
import org.openjdk.jmh.annotations.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
-@State(Scope.Benchmark)
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@State(Scope.Group)
public class StaticDataBenchmarkState {
- public static RedisContainer redis;
- private static PostgreSQLContainer> postgres;
+ public static final int PLAYER_COUNT = 32;
+ private static final int FRIENDS_PER_PLAYER = 8;
+
+ private RedisContainer redis;
+ private PostgreSQLContainer> postgres;
+ private DataManager dataManager;
+ private List playerIds;
+ private List players;
+ private final AtomicInteger nextPlayerIndex = new AtomicInteger();
@Setup(Level.Trial)
public void setup() throws Exception {
- if (postgres == null) {
- redis = new RedisContainer(DockerImageName.parse("redis:6.2.6"));
- redis.start();
-
- redis.execInContainer("redis-cli", "config", "set", "notify-keyspace-events", "KEA");
-
- postgres = new PostgreSQLContainer<>("postgres:16.2")
- .withExposedPorts(5432)
- .withPassword("password")
- .withUsername("postgres")
- .withDatabaseName("postgres");
- postgres.start();
-
- ThreadUtils.setProvider(ThreadUtilProvider.builder().build());
- StaticDataConfig dataSourceConfig = new StaticDataConfig(
- postgres.getHost(),
- postgres.getFirstMappedPort(),
- postgres.getDatabaseName(),
- postgres.getUsername(),
- postgres.getPassword(),
- redis.getHost(),
- redis.getRedisPort(),
- Runnable::run
- );
-
- DataManager dataManager = new DataManager(dataSourceConfig, true);
- dataManager.load(SkyblockPlayer.class);
+ redis = new RedisContainer(DockerImageName.parse("redis:7.4.1"));
+ redis.start();
+ redis.execInContainer("redis-cli", "config", "set", "notify-keyspace-events", "KEA");
+
+ postgres = new PostgreSQLContainer<>("postgres:16.2")
+ .withPassword("password")
+ .withUsername("postgres")
+ .withDatabaseName("postgres");
+ postgres.start();
+
+ ThreadUtils.setProvider(ThreadUtilProvider.builder().build());
+ StaticDataConfig dataSourceConfig = new StaticDataConfig(
+ postgres.getHost(),
+ postgres.getFirstMappedPort(),
+ postgres.getDatabaseName(),
+ postgres.getUsername(),
+ postgres.getPassword(),
+ redis.getHost(),
+ redis.getRedisPort(),
+ Runnable::run
+ );
+
+ dataManager = new DataManager(dataSourceConfig, false);
+ dataManager.load(SkyblockPlayer.class);
+ dataManager.finishLoading();
+
+ List mutablePlayerIds = new ArrayList<>(PLAYER_COUNT);
+ List mutablePlayers = new ArrayList<>(PLAYER_COUNT);
+
+ for (int i = 0; i < PLAYER_COUNT; i++) {
+ UUID settingsId = UUID.randomUUID();
+ SkyblockPlayerSettings.builder(dataManager)
+ .id(settingsId)
+ .tablistPriority(i)
+ .insert(InsertMode.SYNC);
+
+ UUID playerId = UUID.randomUUID();
+ SkyblockPlayer player = SkyblockPlayer.builder(dataManager)
+ .id(playerId)
+ .name("FakePlayer" + i)
+ .settingsId(settingsId)
+ .insert(InsertMode.SYNC);
+
+ mutablePlayerIds.add(playerId);
+ mutablePlayers.add(player);
+ }
+
+ for (int playerIndex = 0; playerIndex < mutablePlayers.size(); playerIndex++) {
+ List friends = new ArrayList<>(FRIENDS_PER_PLAYER);
+ for (int offset = 1; offset <= FRIENDS_PER_PLAYER; offset++) {
+ friends.add(mutablePlayers.get((playerIndex + offset) % mutablePlayers.size()));
+ }
+ mutablePlayers.get(playerIndex).friends.addAll(friends);
+ }
+
+ dataManager.flushTaskQueue();
+ playerIds = Collections.unmodifiableList(mutablePlayerIds);
+ players = Collections.unmodifiableList(mutablePlayers);
+
+ // Populate relation and prepared-statement caches before JMH starts measuring.
+ for (SkyblockPlayer player : players) {
+ player.name.get();
+ player.settings.get();
+ player.friends.forEach(friend -> friend.name.get());
}
}
@TearDown(Level.Trial)
- public void tearDown() throws Exception {
- ThreadUtils.shutdown();
- if (postgres != null) {
- postgres.stop();
- }
+ public void tearDown() {
+ try {
+ if (dataManager != null) {
+ dataManager.flushTaskQueue();
+ }
+ } finally {
+ ThreadUtils.shutdown();
+
+ if (postgres != null) {
+ postgres.stop();
+ }
- if (redis != null) {
- redis.stop();
+ if (redis != null) {
+ redis.stop();
+ }
}
}
+
+ public DataManager dataManager() {
+ return dataManager;
+ }
+
+ public List playerIds() {
+ return playerIds;
+ }
+
+ public UUID nextPlayerId() {
+ int index = Math.floorMod(nextPlayerIndex.getAndIncrement(), playerIds.size());
+ return playerIds.get(index);
+ }
}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayer.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayer.java
index fee78c26..93c0c4c7 100644
--- a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayer.java
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayer.java
@@ -10,7 +10,16 @@ public class SkyblockPlayer extends UniqueData {
@IdColumn(name = "id")
public PersistentValue id;
-
@Column(name = "name")
public PersistentValue name;
+
+ @Column(name = "settings_id")
+ public PersistentValue settingsId;
+
+ @OneToOne(link = "settings_id=id")
+ public Reference settings;
+
+ @Delete(DeleteStrategy.CASCADE)
+ @ManyToMany(link = "id=id", joinTable = "player_friends")
+ public PersistentCollection friends;
}
diff --git a/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayerSettings.java b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayerSettings.java
new file mode 100644
index 00000000..1f09fb6f
--- /dev/null
+++ b/benchmark/src/jmh/java/net/staticstudios/data/benchmark/data/SkyblockPlayerSettings.java
@@ -0,0 +1,19 @@
+package net.staticstudios.data.benchmark.data;
+
+import net.staticstudios.data.Column;
+import net.staticstudios.data.Data;
+import net.staticstudios.data.IdColumn;
+import net.staticstudios.data.PersistentValue;
+import net.staticstudios.data.UniqueData;
+
+import java.util.UUID;
+
+@Data(schema = "skyblock", table = "player_settings")
+public class SkyblockPlayerSettings extends UniqueData {
+
+ @IdColumn(name = "id")
+ public PersistentValue id;
+
+ @Column(name = "tablist_priority")
+ public PersistentValue tablistPriority;
+}
diff --git a/core/src/main/java/net/staticstudios/data/DataManager.java b/core/src/main/java/net/staticstudios/data/DataManager.java
index f16f7104..014f689f 100644
--- a/core/src/main/java/net/staticstudios/data/DataManager.java
+++ b/core/src/main/java/net/staticstudios/data/DataManager.java
@@ -36,7 +36,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.locks.ReentrantLock;
+import java.util.concurrent.locks.StampedLock;
import java.util.function.Consumer;
@ApiStatus.Internal
@@ -1006,12 +1006,7 @@ public void handleDelete(List columnNames, String schema, String table,
ColumnValuePairs key = new ColumnValuePairs(idColumns);
UniqueData instance;
- classCache.lock.lock();
- try {
- instance = classCache.map.remove(key);
- } finally {
- classCache.lock.unlock();
- }
+ instance = classCache.remove(key);
if (instance != null) {
instance.markDeleted();
@@ -1064,19 +1059,7 @@ public void updateIdColumns(List columnNames, String schema, String tabl
ColumnValuePairs newIdCols = new ColumnValuePairs(newIdColumns);
- classCache.lock.lock();
- try {
- UniqueData instance = classCache.map.remove(oldIdCols);
-
- if (instance == null) {
- return;
- }
-
- instance.setIdColumns(newIdCols);
- classCache.map.put(newIdCols, instance);
- } finally {
- classCache.lock.unlock();
- }
+ classCache.move(oldIdCols, newIdCols);
});
}
@@ -1124,6 +1107,19 @@ public T getInstance(Class clazz, ColumnValuePair... i
@SuppressWarnings("unchecked")
public T getInstance(Class clazz, @NotNull ColumnValuePairs idColumns) {
+ InstanceCache classCache = uniqueDataInstanceCache.get(clazz.getName());
+ if (classCache != null) {
+ T cached = (T) classCache.getIfLive(idColumns);
+ if (cached != null) {
+ logger.trace(
+ "Cache hit for UniqueData class {} with ID columnsInReferringTable {}",
+ clazz.getName(),
+ idColumns
+ );
+ return cached;
+ }
+ }
+
UniqueDataMetadata metadata = getMetadata(clazz);
Preconditions.checkNotNull(metadata, "UniqueData class %s has not been parsed yet", clazz.getName());
boolean hasAllIdColumns = true;
@@ -1149,26 +1145,6 @@ public T getInstance(Class clazz, @NotNull ColumnValue
T instance;
- InstanceCache classCache = uniqueDataInstanceCache.get(clazz.getName());
-
- if (classCache != null) {
- classCache.lock.lock();
- try {
- instance = (T) classCache.map.get(idColumns);
-
- if (instance != null && !instance.isDeleted()) {
- logger.trace(
- "Cache hit for UniqueData class {} with ID columnsInReferringTable {}",
- clazz.getName(),
- idColumns
- );
- return instance;
- }
- } finally {
- classCache.lock.unlock();
- }
- }
-
try {
Constructor constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
@@ -1237,30 +1213,19 @@ public T getInstance(Class clazz, @NotNull ColumnValue
k -> new InstanceCache()
);
- cache.lock.lock();
- try {
- UniqueData existing = cache.map.get(idColumns);
+ UniqueData cached = cache.putIfAbsentLive(idColumns, instance);
- if (existing != null && !existing.isDeleted()) {
- return (T) existing;
- }
-
- if (existing != null) {
- cache.map.remove(idColumns, existing);
- }
-
- cache.map.put(idColumns, instance);
+ if (cached != instance) {
+ return (T) cached;
+ }
- logger.trace(
- "Cache miss for UniqueData class {} with ID columnsInReferringTable {}. Created new instance.",
- clazz.getName(),
- idColumns
- );
+ logger.trace(
+ "Cache miss for UniqueData class {} with ID columnsInReferringTable {}. Created new instance.",
+ clazz.getName(),
+ idColumns
+ );
- return instance;
- } finally {
- cache.lock.unlock();
- }
+ return instance;
}
/**
@@ -1833,7 +1798,72 @@ private Set resolveCells(List columnNames, String schema, String t
private static final class InstanceCache {
final ConcurrentMap map = new MapMaker().weakValues().makeMap();
- final ReentrantLock lock = new ReentrantLock();
+ final StampedLock lock = new StampedLock();
+
+ // Cache hits dominate this path. Optimistic reads avoid serializing readers while
+ // the fallback preserves atomicity when an ID move, delete, or insertion overlaps.
+ @Nullable
+ UniqueData getIfLive(ColumnValuePairs idColumns) {
+ long stamp = lock.tryOptimisticRead();
+ UniqueData instance = map.get(idColumns);
+ boolean live = instance != null && !instance.isDeleted();
+
+ if (lock.validate(stamp)) {
+ return live ? instance : null;
+ }
+
+ stamp = lock.readLock();
+ try {
+ instance = map.get(idColumns);
+ return instance != null && !instance.isDeleted() ? instance : null;
+ } finally {
+ lock.unlockRead(stamp);
+ }
+ }
+
+ @Nullable
+ UniqueData remove(ColumnValuePairs idColumns) {
+ long stamp = lock.writeLock();
+ try {
+ return map.remove(idColumns);
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ void move(ColumnValuePairs oldIdColumns, ColumnValuePairs newIdColumns) {
+ long stamp = lock.writeLock();
+ try {
+ UniqueData instance = map.remove(oldIdColumns);
+ if (instance == null) {
+ return;
+ }
+
+ instance.setIdColumns(newIdColumns);
+ map.put(newIdColumns, instance);
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
+
+ UniqueData putIfAbsentLive(ColumnValuePairs idColumns, UniqueData candidate) {
+ long stamp = lock.writeLock();
+ try {
+ UniqueData existing = map.get(idColumns);
+ if (existing != null && !existing.isDeleted()) {
+ return existing;
+ }
+
+ if (existing != null) {
+ map.remove(idColumns, existing);
+ }
+
+ map.put(idColumns, candidate);
+ return candidate;
+ } finally {
+ lock.unlockWrite(stamp);
+ }
+ }
}
}
diff --git a/core/src/main/java/net/staticstudios/data/util/ColumnValuePair.java b/core/src/main/java/net/staticstudios/data/util/ColumnValuePair.java
index 6f2d16cf..b790bb28 100644
--- a/core/src/main/java/net/staticstudios/data/util/ColumnValuePair.java
+++ b/core/src/main/java/net/staticstudios/data/util/ColumnValuePair.java
@@ -5,10 +5,12 @@
public final class ColumnValuePair {
private final String column;
private final Object value;
+ private final int hashCode;
public ColumnValuePair(String column, Object value) {
this.column = column;
this.value = value;
+ this.hashCode = 31 * Objects.hashCode(column) + Objects.hashCode(value);
}
public static ColumnValuePair of(String column, Object value) {
@@ -34,7 +36,7 @@ public boolean equals(Object obj) {
@Override
public int hashCode() {
- return Objects.hash(column, value);
+ return hashCode;
}
@Override
diff --git a/core/src/main/java/net/staticstudios/data/util/ColumnValuePairs.java b/core/src/main/java/net/staticstudios/data/util/ColumnValuePairs.java
index 68a4f8a4..547d71c7 100644
--- a/core/src/main/java/net/staticstudios/data/util/ColumnValuePairs.java
+++ b/core/src/main/java/net/staticstudios/data/util/ColumnValuePairs.java
@@ -10,10 +10,12 @@ public final class ColumnValuePairs implements Iterable {
public static final ColumnValuePairs EMPTY = new ColumnValuePairs();
private final ColumnValuePair[] pairs;
+ private final int hashCode;
public ColumnValuePairs(ColumnValuePair... pairs) {
this.pairs = pairs.clone();
Arrays.sort(this.pairs, Comparator.comparing(ColumnValuePair::column));
+ this.hashCode = Arrays.hashCode(this.pairs);
}
public static Object getValue(String column, ColumnValuePairs pairs) {
@@ -67,7 +69,7 @@ public boolean equals(Object obj) {
@Override
public int hashCode() {
- return Arrays.hashCode(pairs);
+ return hashCode;
}
@Override
|