Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 36 additions & 18 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>()
val refViolations = mutableListOf<String>()
ZipFile(jarFile.get().asFile).use { zip ->
Expand Down Expand Up @@ -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 <clinit>-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
Expand All @@ -946,7 +964,7 @@ val gateReports = layout.buildDirectory.dir("verify-gates")

val verifyRelocation = tasks.register<VerifyRelocationTask>("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") })
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <b>NOT</b> 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).
*
* <p><b>Owning thread(s):</b> stateless. <b>Mutability:</b> immutable singleton.
*/
Expand Down Expand Up @@ -35,7 +36,7 @@ public String driverClassName() {

/**
* Builds the H2 file-DB URL: {@code jdbc:h2:file:<path>;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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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. */
Expand Down
48 changes: 40 additions & 8 deletions docs/design/per-version-dependencies.md
Original file line number Diff line number Diff line change
@@ -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 `<clinit>`-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

---

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading