diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 88fe96a..4dccf77 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -100,8 +100,8 @@ dependencies { implementation(libs.adventure.api) { exclude(group = "org.jetbrains", module = "annotations") } implementation(libs.adventure.minimessage) { exclude(group = "org.jetbrains", module = "annotations") } implementation(libs.adventure.serializer.legacy) { exclude(group = "org.jetbrains", module = "annotations") } - implementation(libs.hikaricp) - implementation(libs.slf4j.nop) // provides the relocated slf4j binder (see catalog note) + implementation(libs.commons.dbcp2) // connection pool (Java-8-native latest; pulls commons-pool2 + commons-logging) + implementation(libs.commons.pool2) implementation(libs.h2) implementation(libs.mysql) implementation(libs.bstats.bukkit) @@ -136,11 +136,12 @@ tasks.shadowJar { relocate("net.kyori", "dev.fablemc.factions.lib.adventure") relocate("org.bstats", "dev.fablemc.factions.lib.bstats") - relocate("com.zaxxer.hikari", "dev.fablemc.factions.lib.hikari") + relocate("org.apache.commons.dbcp2", "dev.fablemc.factions.lib.dbcp2") + relocate("org.apache.commons.pool2", "dev.fablemc.factions.lib.pool2") + // DBCP2/pool2 log via commons-logging — shade it too (no slf4j binding needed). + relocate("org.apache.commons.logging", "dev.fablemc.factions.lib.commonslogging") relocate("org.h2", "dev.fablemc.factions.lib.h2") relocate("com.mysql", "dev.fablemc.factions.lib.mysql") - // HikariCP 4.x transitively needs slf4j-api — shade it too (AM-10). - relocate("org.slf4j", "dev.fablemc.factions.lib.slf4j") // AM-10: the relocated JDBC drivers use explicit driverClassName; drop the service // file, and strip third-party Multi-Release trees before the downgrade input. @@ -176,10 +177,18 @@ tasks.shadowJar { exclude("**/h2/tools/Shell*") // references server/web ConnectionInfo exclude("**/h2/util/DbDriverActivator*") // org.osgi BundleActivator exclude("**/h2/util/OsgiDataSourceFactory*") // org.osgi.service.jdbc - exclude("**/hikari/metrics/prometheus/**") // io.prometheus (not linked by core) - exclude("**/hikari/hibernate/**") // org.hibernate ConnectionProvider - exclude("**/hikari/util/JavassistProxyFactory*") // org.javassist (build-time proxy gen) exclude("**/mysql/cj/jdbc/integration/**") // com.mchange c3p0 ConnectionTester + // commons-logging's servlet-container cleanup listener implements javax.servlet.* — javax/ is + // an un-ignorable HARD verifyJdk8Api tier (route 1, like the H2 web console), and Paper is not + // a servlet container, so strip it. (Its optional slf4j/cglib bridges stay bundled but dead — + // route 2, ignored below — since org/slf4j and net/sf/cglib ARE ignorable external optionals.) + // NB shadow filters on the SOURCE path, so match the un-relocated org/apache/commons/logging. + exclude("**/commons/logging/impl/ServletContextCleaner*") + // DBCP2's optional MANAGED (XA/JTA) datasource pulls javax.transaction, which in turn references + // javax.enterprise/interceptor (CDI) — un-ignorable javax/ HARD packages. We use the plain + // BasicDataSource, never the managed pool, so strip that package and the bundled JTA API. + exclude("**/dbcp2/managed/**") + exclude("javax/transaction/**") // AuthenticationOciClient stays BUNDLED (route 2): it names only com.oracle.bmc // externally, which verifyJdk8Api ignores as a guarded-optional SDK (never linked on the // native/basic-auth path). Excluding it would dangle NativeAuthenticationProvider's ref. @@ -476,7 +485,8 @@ abstract class VerifyRelocationTask : DefaultTask() { @TaskAction fun run() { val libPrefix = "dev/fablemc/factions/lib/" - val tokens = listOf("net/kyori", "com/zaxxer", "org/h2", "com/mysql", "org/slf4j") + val tokens = listOf("net/kyori", "org/apache/commons/dbcp2", "org/apache/commons/pool2", + "org/apache/commons/logging", "org/h2", "com/mysql") val entryViolations = mutableListOf() val refViolations = mutableListOf() ZipFile(jarFile.get().asFile).use { zip -> @@ -922,14 +932,22 @@ val serverProvidedIgnores = listOf( "net/md_5", "net/milkbowl", "com/sk89q", "me/clip", "org/dynmap", "com/earth2me", "github/scarsz", "com/griefcraft", "org/apache", "org/yaml", // Guarded-optional third-party integrations HARD-referenced by BUNDLED classes of the - // shaded storage libs, but never LINKED by the embedded-DB/JDBC path: HikariPool casts - // to the Dropwizard/Micrometer metric registries only when one is configured - // (string-guarded); H2's ValueGeometry touches JTS only when the optional JTS lib is - // present; the MySQL OCI auth plugin (AuthenticationOciClient) reads com.oracle.bmc only - // when authenticationPlugins names it. All external SDKs never on our path, so never a - // runtime miss — the Mental com/viaversion precedent (a guarded optional integration - // ignored rather than bundled). The allowlist stays EMPTY. - "com/codahale", "io/micrometer", "org/locationtech", "com/oracle", + // shaded storage libs, but never LINKED by the embedded-DB/JDBC path: H2's ValueGeometry + // touches JTS only when the optional JTS lib is present; the MySQL OCI auth plugin + // (AuthenticationOciClient) reads com.oracle.bmc only when authenticationPlugins names it; + // the MySQL OpenTelemetry handler (cj.otel, new in connector-j 9.x) touches io.opentelemetry + // only when its -guarded Class.forName("io.opentelemetry.api.GlobalOpenTelemetry") + // succeeds — absent, NativeSession falls back to NoopTelemetryHandler and the handler's + // io.opentelemetry-typed members never link. All external SDKs never on our path, so never a + // runtime miss — the Mental com/viaversion precedent (a guarded optional integration ignored + // rather than bundled). The allowlist stays EMPTY. (DBCP2/pool2 carry no such optional SDK ref.) + "org/locationtech", "com/oracle", "io/opentelemetry", + // commons-logging bundles optional bridges to slf4j (Slf4jLogFactory) and commons-pool2 an + // optional cglib proxy source — both guarded (commons-logging discovers a Log impl in a + // try/catch and we ship no slf4j; pool2 defaults to JDK proxies and we ship no cglib), so the + // classes are dead and their org/slf4j + net/sf/cglib references never link. Ignorable external + // optionals (route 2), same as the OCI/OTel SDKs above. + "org/slf4j", "net/sf/cglib", ) // A relocated FIRST-PARTY class we were FORCED to strip from the shaded jar (route (1) in @@ -946,7 +964,7 @@ val gateReports = layout.buildDirectory.dir("verify-gates") val verifyRelocation = tasks.register("verifyRelocation") { group = "verification" - description = "Fails if net/kyori, com/zaxxer, org/h2, com/mysql or org/slf4j survives outside the lib prefix." + description = "Fails if net/kyori, org/apache/commons/{dbcp2,pool2,logging}, org/h2 or com/mysql survives outside the lib prefix." jarFile.set(canonicalJar) report.set(gateReports.map { it.file("relocation.txt") }) } diff --git a/core/src/main/java/dev/fablemc/factions/core/storage/H2Dialect.java b/core/src/main/java/dev/fablemc/factions/core/storage/H2Dialect.java index 977e024..46dee10 100644 --- a/core/src/main/java/dev/fablemc/factions/core/storage/H2Dialect.java +++ b/core/src/main/java/dev/fablemc/factions/core/storage/H2Dialect.java @@ -1,10 +1,11 @@ package dev.fablemc.factions.core.storage; /** - * The H2 backend dialect (AM-10). Upserts rewrite to H2's {@code MERGE INTO … KEY(…)} form, - * which — unlike {@code ON DUPLICATE KEY UPDATE} — H2 1.4.200 implements natively. The JDBC URL - * uses {@code MODE=MySQL;DB_CLOSE_DELAY=-1} and pool size 1 (single-writer file DB); it does - * NOT use {@code NON_KEYWORDS} (that flag is H2 2.x-only — AM-10). + * The H2 backend dialect. Upserts rewrite to H2's {@code MERGE INTO … KEY(…)} form, which — unlike + * {@code ON DUPLICATE KEY UPDATE} — H2 implements natively. The JDBC URL uses + * {@code MODE=MySQL;DB_CLOSE_DELAY=-1} and pool size 1 (single-writer file DB). Backed by H2 + * 2.2.224 (the latest Java-8-native line); the schema + MySQL mode clears 2.x's stricter reserved + * words without needing the {@code NON_KEYWORDS} flag (the full-DDL storage tests confirm it). * *

Owning thread(s): stateless. Mutability: immutable singleton. */ @@ -35,7 +36,7 @@ public String driverClassName() { /** * Builds the H2 file-DB URL: {@code jdbc:h2:file:;MODE=MySQL;DB_CLOSE_DELAY=-1}. No - * {@code NON_KEYWORDS} flag (AM-10 — that is H2 2.x-only and would fail on the pinned 1.4.200). + * {@code NON_KEYWORDS} flag is needed — the schema clears H2 2.x's reserved words in MySQL mode. */ public static String fileUrl(String absolutePath) { return "jdbc:h2:file:" + absolutePath + ";MODE=MySQL;DB_CLOSE_DELAY=-1"; diff --git a/core/src/main/java/dev/fablemc/factions/core/storage/StorageBoot.java b/core/src/main/java/dev/fablemc/factions/core/storage/StorageBoot.java index 788a6a2..64754c3 100644 --- a/core/src/main/java/dev/fablemc/factions/core/storage/StorageBoot.java +++ b/core/src/main/java/dev/fablemc/factions/core/storage/StorageBoot.java @@ -14,8 +14,7 @@ import java.util.function.ToIntFunction; import java.util.logging.Logger; -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; +import org.apache.commons.dbcp2.BasicDataSource; import dev.fablemc.factions.core.storage.load.BaselineLoader; import dev.fablemc.factions.kernel.config.ConfigImage; @@ -39,14 +38,14 @@ */ public final class StorageBoot implements AutoCloseable { - private final HikariDataSource dataSource; + private final BasicDataSource dataSource; private final SqlDialect dialect; private final String backendLabel; private final AdvisoryLock lock; private final StorageProjector projector; private final BaselineLoader loader; - private StorageBoot(HikariDataSource dataSource, SqlDialect dialect, String backendLabel, + private StorageBoot(BasicDataSource dataSource, SqlDialect dialect, String backendLabel, AdvisoryLock lock, StorageProjector projector, BaselineLoader loader) { this.dataSource = dataSource; this.dialect = dialect; @@ -95,7 +94,7 @@ public static StorageBoot open(StorageConfigView view, String mysqlPassword, Fil String label = mysql ? "MySQL" : "H2"; String dbKey = mysql ? view.mysqlDatabase() : view.h2File(); - HikariDataSource ds = buildPool(view, dialect, mysqlPassword, dataFolder, mysql); + BasicDataSource ds = buildPool(view, dialect, mysqlPassword, dataFolder, mysql); AdvisoryLock lock = null; try { try (Connection conn = ds.getConnection()) { @@ -111,11 +110,11 @@ public static StorageBoot open(StorageConfigView view, String mysqlPassword, Fil return new StorageBoot(ds, dialect, label, lock, projector, loader); } catch (SQLException ex) { releaseLock(lock); - ds.close(); + closeQuietly(ds); throw new StorageException("storage boot failed", ex); } catch (RuntimeException ex) { releaseLock(lock); // finding #17: a post-lock failure must release the lock, not leak it - ds.close(); + closeQuietly(ds); throw ex; } } @@ -237,25 +236,35 @@ public void close() { try { lock.close(); } finally { - dataSource.close(); + closeQuietly(dataSource); } } - private static HikariDataSource buildPool(StorageConfigView view, SqlDialect dialect, + private static BasicDataSource buildPool(StorageConfigView view, SqlDialect dialect, String mysqlPassword, File dataFolder, boolean mysql) { - HikariConfig cfg = new HikariConfig(); - cfg.setPoolName("fable-storage-pool"); - cfg.setDriverClassName(dialect.driverClassName()); + BasicDataSource ds = new BasicDataSource(); + // Explicit relocated driver class name (AM-10): the shaded jar excludes the JDBC service + // file, so the pool must be told which driver to load rather than discover it. + ds.setDriverClassName(dialect.driverClassName()); if (mysql) { - cfg.setJdbcUrl(MySqlDialect.url(view.mysqlHost(), view.mysqlPort(), view.mysqlDatabase())); - cfg.setUsername(view.mysqlUsername()); - cfg.setPassword(mysqlPassword == null ? "" : mysqlPassword); - cfg.setMaximumPoolSize(Math.max(1, view.mysqlPoolSize())); + ds.setUrl(MySqlDialect.url(view.mysqlHost(), view.mysqlPort(), view.mysqlDatabase())); + ds.setUsername(view.mysqlUsername()); + ds.setPassword(mysqlPassword == null ? "" : mysqlPassword); + ds.setMaxTotal(Math.max(1, view.mysqlPoolSize())); } else { - cfg.setJdbcUrl(h2Url(view.h2File(), dataFolder)); - cfg.setMaximumPoolSize(1); // single-writer file/mem DB (AM-10) + ds.setUrl(h2Url(view.h2File(), dataFolder)); + ds.setMaxTotal(1); // single-writer file/mem DB (AM-10) + } + return ds; + } + + /** Closes a DBCP2 pool, swallowing the checked close exception (best-effort cleanup path). */ + private static void closeQuietly(BasicDataSource ds) { + try { + ds.close(); + } catch (SQLException ignored) { + // boot-failure / shutdown cleanup: a pool that won't close cleanly is dropped anyway } - return new HikariDataSource(cfg); } /** Builds the H2 JDBC URL: a {@code mem:} handle passes through; otherwise a file DB under the data folder. */ diff --git a/docs/design/per-version-dependencies.md b/docs/design/per-version-dependencies.md index f5c9f17..9927fd7 100644 --- a/docs/design/per-version-dependencies.md +++ b/docs/design/per-version-dependencies.md @@ -1,13 +1,45 @@ -# Evaluation — per-version dependency bundling +# Dependency modernization — what shipped and why + +**Status: DONE (v1 beta.3).** Wave 5 asked whether the mega-jar could run newer dependencies +across the whole 1.7.10→26.x range. The original question — *bundle a different version per +Minecraft/Java version* — is unsound (see "Why naïve Multi-Release override does NOT work" +below: MR overrides can't version-swap a library's class graph). But the project owner's +reframing works and is now **implemented**: ship a **single, newest** version of each dependency +and let the same jvmdg pipeline that downgrades our own bytecode downgrade *it* too — plus a +`build-high` variant-selection tweak for deps that publish a high `org.gradle.jvm.version`. + +## Shipped dependency set (beta.3) + +| Dependency | was | now | how | +|---|---|---|---| +| Connection pool | HikariCP 4.0.3 (pinned) | **Apache Commons DBCP2 2.14.0** (latest) | migrated (`StorageBoot`): DBCP2's newest release is Java-8-native, so it drops into the v52 base tier with no jvmdg tricks — **removes the HikariCP pin** | +| Logging binder | slf4j-nop 1.7.30 (pinned) | **gone** | DBCP2 logs via commons-logging (bundled) — **removes the slf4j pin** | +| MySQL driver | 8.0.33 | **9.7.0** (latest) | consumed as-is; the optional OpenTelemetry handler is ``-guarded and ignored | +| Embedded DB | H2 1.4.200 (pinned) | **H2 2.2.224** | the latest **Java-8-native** H2 line (2.3.x needs Java 11 and jvmdg can't resolve its excluded `tools/Server` on downgrade). Our schema + MODE=MySQL clears 2.x's reserved words with no `NON_KEYWORDS`. Beta: no on-disk migration from the 1.4.200 format — a fresh DB is created | +| bStats | 3.2.1 | 3.2.1 | already latest | +| Text | Adventure 4.26.1 | **held at 4.26.1** (latest 4.x) | see below | + +First-party v52/v57/v61 tiers are untouched (`options.release` stays 17), and the jar stays a clean +base/v13/v17 Multi-Release with no extra tier. Full build + all 7 gates + tests green (the larger +dep set needed the daemon heap raised to 6g so jvmdg's shade stays under its internal timeout). + +### Held: Adventure 5.x (deferred, not rejected) + +Adventure 5.2.0 IS consumable via build-high (jvm.version=21 → jvmdg downgrade) with **zero +`.java` change** — proven green locally. But 5.x pulls in many more modern-JDK APIs +(`StringConcatFactory`, `HexFormat`, `RandomGenerator`, `MatchException`, `Locale`…), and jvmdg's +shim-stub set for them composes **non-deterministically** with the `versions/13` + `versions/21` +tiers: it passed locally but `verifyJdk8Api` flagged ~17 missing base-tree jvmdg stubs in CI (a +different environment/ordering surfaced the gap). The `mergeTiers` dedup even reported repeated +`versions/13` stub entries. Root cause is the three-way tier composition (base v52 / v57 versions-13 +/ v65 versions-21) not producing a complete, deterministic jvmdg-stub closure. Fixing it needs a +deterministic multi-tier stub-merge (compute the full stub closure per tier and inject it +explicitly), which is its own build-surgery task. Until then Adventure stays on the latest 4.x line +(Java-8-native, base tier) — the other four dependencies modernized without it. -**Status: DECISION (v1 beta).** Question raised in wave 5: can the mega-jar carry -*different dependency versions per Minecraft/Java version* — newest deps for modern -servers, last-supported deps for legacy — the way it already carries per-version -**first-party** bytecode (v52 / v57 / v61 tiers)? +--- -**Verdict: not for v1. Keep the single pinned Java-8-line stack (AM-10). The mechanism -that would make it safe is documented below as a post-v1 option, to be revisited only when -a *concrete* modern-only dependency win is identified.** +## Appendix — why the *per-version bundling* framing was rejected --- diff --git a/gradle.properties b/gradle.properties index acca3ab..4efb92c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,4 @@ org.gradle.caching=true # capture); shadow/jvmdg/run-paper are compatible as of the pinned versions. org.gradle.configuration-cache=true -org.gradle.jvmargs=-Xmx3g -XX:+UseParallelGC -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx6g -XX:+UseParallelGC -Dfile.encoding=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97d5924..467d14a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,19 +11,31 @@ paper-floor = "1.13.2-R0.1-SNAPSHOT" paper-folia = "1.20.4-R0.1-SNAPSHOT" paper-modern = "1.20.6-R0.1-SNAPSHOT" -# Shaded + relocated into the FableFactions jar (dev.fablemc.factions.lib.adventure) -# so a self-contained Adventure — the legacy-string serializer especially — is -# present below Paper 1.16.5 where net.kyori is absent from the server. If -# verifyJdk8Api later flags an adventure class as using a post-Java-8 API, drop to -# 4.14.0 (the last line proven Java-8-clean) and record it in the build notes. +# Shaded + relocated into the FableFactions jar (dev.fablemc.factions.lib.adventure) so a +# self-contained Adventure — the legacy-string serializer especially — is present below Paper +# 1.16.5 where net.kyori is absent from the server. Held at the latest 4.x (Java-8-native, base +# tier). Adventure 5.x IS consumable via build-high (jvm.version=21 → downgrade), and the API +# needs no .java change — but 5.x pulls in far more modern-JDK APIs, and jvmdg's shim-stub set for +# them composes non-deterministically with the versions/13 + versions/21 tiers (green locally, but +# verifyJdk8Api flagged missing base-tree stubs in CI). Deferred until the multi-tier stub merge is +# made deterministic; the other deps modernized without it. adventure = "4.26.1" -# Java-8-line storage stack, pinned (AM-10): HikariCP 4.0.3 is the last Java-8 line, -# H2 1.4.200 is Java-8 compatible (NO NON_KEYWORDS URL flag — that is H2 2.x-only), -# mysql-connector-j 8.0.33. All three shaded + relocated under dev.fablemc.factions.lib.*. -hikari = "4.0.3" -h2 = "1.4.200" -mysql = "8.0.33" +# Storage stack, shaded + relocated under dev.fablemc.factions.lib.*. The connection pool is +# Apache Commons DBCP2 (with commons-pool2): its LATEST releases still target Java 8, so they +# drop straight into the v52 base tier with no jvmdg downgrade — unlike HikariCP, whose 5.x+ +# line needs Java 11 + unshimmable JDBC-4.3 methods. DBCP2 logs via commons-logging, so this +# also removed the slf4j pin. mysql-connector-j 9.x is consumed as-is (its Java-9+ bytecode is +# jvmdg-downgraded; the optional OpenTelemetry handler is -guarded). H2 1.4.200 stays +# on the Java-8 line (2.x changes the on-disk format + SQL dialect — a data migration, deferred). +commons-dbcp2 = "2.14.0" +commons-pool2 = "2.13.1" +# H2 2.2.224 is the last Java-8-native H2 line (2.3.x moved to a Java-11 floor); like DBCP2 it +# drops into the v52 base tier with no jvmdg downgrade. Our schema + MODE=MySQL survives 2.x's +# stricter reserved-word set as-is (the full-DDL storage tests pass without NON_KEYWORDS). (Beta: +# no on-disk migration from the old 1.4.200 format — a fresh DB is created, per the project owner.) +h2 = "2.2.224" +mysql = "9.7.0" bstats = "3.2.1" # compileOnly ONLY (soft-dep, never shaded): the typed FableExpansion subclass needs the @@ -53,13 +65,10 @@ adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" } adventure-serializer-legacy = { module = "net.kyori:adventure-text-serializer-legacy", version.ref = "adventure" } -hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } -# HikariCP logs via slf4j-api (shaded transitively); slf4j-api's LoggerFactory statically -# references org.slf4j.impl.Static*Binder, which a binding must provide. Bundle the no-op -# binding (relocated with the rest of slf4j) so those references resolve in-jar for -# verifyJdk8Api and pool logging is silent rather than a startup warning. Pinned to the -# slf4j-api line HikariCP 4.0.3 pulls (1.7.x). -slf4j-nop = { module = "org.slf4j:slf4j-nop", version = "1.7.30" } +# Apache Commons DBCP2 connection pool + its commons-pool2 backend; both pull commons-logging +# transitively (all Java-8-native, so no jvmdg shim and no slf4j binding to bundle). +commons-dbcp2 = { module = "org.apache.commons:commons-dbcp2", version.ref = "commons-dbcp2" } +commons-pool2 = { module = "org.apache.commons:commons-pool2", version.ref = "commons-pool2" } h2 = { module = "com.h2database:h2", version.ref = "h2" } mysql = { module = "com.mysql:mysql-connector-j", version.ref = "mysql" } bstats-bukkit = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" }