From 1fb794f3397d348605734c5e018bf606565194eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Le=20Callonnec?= Date: Tue, 5 Dec 2023 18:26:01 +0000 Subject: [PATCH] Add JMX support for statement cache, and unit tests. (#1972) JMX allows a developer to use `jconsole` to connect to the app, and introspect various metrics about it. This PR adds metrics about the `PreparedStatement` cache, and allows a developer to turn on/off at runtime on demand. --- build.gradle | 10 +- src/main/java/core/HO.java | 19 ++++ src/main/java/core/db/ConnectionManager.java | 4 + src/main/java/core/db/StatementCache.java | 67 ------------ src/main/java/core/db/StatementCache.kt | 101 ++++++++++++++++++ .../java/core/jmx/StatementCacheMonitor.kt | 22 ++++ .../core/jmx/StatementCacheMonitorMBean.kt | 10 ++ src/main/java/module/lineup/Lineup.java | 28 +---- .../lineup/assistant/LineupAssistant.java | 11 +- .../assistant/LineupAssistantPanel.java | 3 +- .../module/teamAnalyzer/SystemManager.java | 19 +--- .../teamAnalyzer/TeamAnalyzerModule.java | 2 +- .../teamAnalyzer/ht/HattrickManager.java | 1 - .../module/teamAnalyzer/ui/RatingUtil.java | 4 +- src/test/java/core/db/BasicsTableTest.kt | 16 ++- src/test/java/core/db/DBInfoTest.kt | 10 ++ src/test/java/core/db/StatementCacheTest.kt | 85 +++++++++++++++ 17 files changed, 283 insertions(+), 129 deletions(-) delete mode 100644 src/main/java/core/db/StatementCache.java create mode 100644 src/main/java/core/db/StatementCache.kt create mode 100644 src/main/java/core/jmx/StatementCacheMonitor.kt create mode 100644 src/main/java/core/jmx/StatementCacheMonitorMBean.kt create mode 100644 src/test/java/core/db/StatementCacheTest.kt diff --git a/build.gradle b/build.gradle index 1eb89c30d..7ca802f85 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,9 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id 'java' id 'groovy' - id 'org.jetbrains.kotlin.jvm' version '1.9.10' + id 'org.jetbrains.kotlin.jvm' version '1.9.21' id 'application' id 'de.jansauer.poeditor' version '1.1.0' id 'org.kordamp.gradle.markdown' version '2.2.0' @@ -111,6 +113,12 @@ java { // modularity.inferModulePath.set(true) } +tasks.withType(KotlinCompile).all { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } +} + sourceSets { main { java { diff --git a/src/main/java/core/HO.java b/src/main/java/core/HO.java index ed3efe7c7..22d48b662 100644 --- a/src/main/java/core/HO.java +++ b/src/main/java/core/HO.java @@ -8,6 +8,7 @@ import core.gui.model.UserColumnController; import core.gui.theme.ImageUtilities; import core.gui.theme.ThemeManager; +import core.jmx.StatementCacheMonitor; import core.model.HOVerwaltung; import core.model.UserParameter; import core.training.TrainingManager; @@ -16,7 +17,9 @@ import core.util.OSUtils; import java.io.File; import javax.imageio.ImageIO; +import javax.management.*; import javax.swing.*; +import java.lang.management.ManagementFactory; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Arrays; @@ -212,6 +215,7 @@ public static void main(String[] args) { DBManager.instance().updateConfig(); } + initJmxSupport(); // Training interruptionWindow.setInfoText(8, "Initialize Training"); @@ -230,6 +234,21 @@ public static void main(String[] args) { }); } + private static void initJmxSupport() { + if (HO.isDevelopment()) { + try { + MBeanServer platformMBeanServer = ManagementFactory.getPlatformMBeanServer(); + platformMBeanServer.registerMBean( + new StatementCacheMonitor(), + new ObjectName("io.github.ho-dev:name=StatementCacheMonitor") + ); + } catch (MalformedObjectNameException | NotCompliantMBeanException | InstanceAlreadyExistsException | + MBeanRegistrationException e) { + throw new RuntimeException(e); + } + } + } + private static Object[] createOptionsArray() { var buttons = new ArrayList(); int keyEvent = VK_1; diff --git a/src/main/java/core/db/ConnectionManager.java b/src/main/java/core/db/ConnectionManager.java index b8ca199fc..863901f67 100644 --- a/src/main/java/core/db/ConnectionManager.java +++ b/src/main/java/core/db/ConnectionManager.java @@ -15,6 +15,10 @@ public class ConnectionManager { private StatementCache statementCache; + public StatementCache getStatementCache() { + return this.statementCache; + } + /** * Closes the connection */ diff --git a/src/main/java/core/db/StatementCache.java b/src/main/java/core/db/StatementCache.java deleted file mode 100644 index f7dc5e90f..000000000 --- a/src/main/java/core/db/StatementCache.java +++ /dev/null @@ -1,67 +0,0 @@ -package core.db; - -import core.util.HOLogger; - -import java.sql.PreparedStatement; -import java.sql.SQLException; -import java.time.Instant; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -public class StatementCache { - private final ConnectionManager connectionManager; - - private boolean cachedEnabled = true; - - private final Map cache = Collections.synchronizedMap(new HashMap<>()); - - private final Map statementStats = Collections.synchronizedMap(new HashMap<>()); - - public StatementCache(ConnectionManager connectionManager) { - this.connectionManager = connectionManager; - } - - public void setCachedEnabled(boolean enabled) { - cachedEnabled = enabled; - } - - private PreparedStatement getFromCache(String query) { - if (cachedEnabled) { - PreparedStatement statement = cache.get(query); - if (statement != null) { - CachedStatementStats stats = statementStats.get(query); - statementStats.put(query, new CachedStatementStats(stats.created, Instant.now(), stats.count + 1)); - return statement; - } - } - return null; - } - - private PreparedStatement createStatement(String query) { - PreparedStatement statement = null; - try { - statement = connectionManager.connection.prepareStatement(query); - if (cachedEnabled) { - cache.put(query, statement); - statementStats.put(query, new CachedStatementStats(Instant.now(), Instant.now(), 1)); - } - } catch (SQLException e) { - HOLogger.instance().error(StatementCache.class, "Error creating statement: " + query - + "\n Error: " + e.getMessage()); - } - - return statement; - } - - public PreparedStatement getPreparedStatement(String query) { - PreparedStatement statement = getFromCache(query); - if (statement == null) { - statement = createStatement(query); - } - return statement; - } - - - record CachedStatementStats(Instant created, Instant lastAccessed, int count) {} -} diff --git a/src/main/java/core/db/StatementCache.kt b/src/main/java/core/db/StatementCache.kt new file mode 100644 index 000000000..f644a0b15 --- /dev/null +++ b/src/main/java/core/db/StatementCache.kt @@ -0,0 +1,101 @@ +package core.db + +import core.util.HOLogger +import java.sql.PreparedStatement +import java.sql.SQLException +import java.time.Instant +import java.util.* + +/** + * Cache for [PreparedStatement]s instances. + * + * + * This cache tracks statistics about the various prepared statements: + * + * * Creation timestamp, + * * Last access timestamp, + * * NUmber of accesses. + * + * + * + * The cache can be disabled by setting `cachedEnabled` to `false`. When the cache + * is disabled, the existing entries are closed and evicted, the stats dumped and cleared. The cache can + * be enabled or disabled via JMX in development mode. By default, the cache is on. + */ +class StatementCache(private val connectionManager: ConnectionManager) { + var cachedEnabled = true + set(enabled) { + field = enabled + HOLogger.instance().info(StatementCache::class.java, "Cache enabled = $enabled") + if (!field) { + clearCache() + } + } + + private val cache = Collections.synchronizedMap(HashMap()) + + val statementStats: MutableMap = Collections.synchronizedMap(HashMap()) + + private fun getFromCache(query: String): PreparedStatement? { + if (cachedEnabled) { + val statement = cache[query] + if (statement != null) { + val stats = statementStats[query] + statementStats[query] = CachedStatementStats(stats!!.created, Instant.now(), stats.count + 1) + return statement + } + } + return null + } + + private fun createStatement(query: String): PreparedStatement? { + var statement: PreparedStatement? = null + try { + statement = connectionManager.connection.prepareStatement(query) + if (cachedEnabled) { + cache[query] = statement + statementStats[query] = CachedStatementStats(Instant.now(), Instant.now(), 1) + } + } catch (e: SQLException) { + HOLogger.instance().error( + StatementCache::class.java, """Error creating statement: $query + Error: ${e.message}""" + ) + } + return statement + } + + fun getPreparedStatement(query: String): PreparedStatement? { + var statement = getFromCache(query) + if (statement == null) { + statement = createStatement(query) + } + return statement + } + + fun clearCache() { + for ((key, value) in cache) { + try { + value!!.close() + } catch (e: SQLException) { + HOLogger.instance().error( + StatementCache::class.java, + """Error closing prepared statement: $key + ${e.message}""" + ) + } + } + cache.clear() + dumpStats() + statementStats.clear() + } + + fun dumpStats() { + for ((key, value) in statementStats) { + HOLogger.instance().info(StatementCache::class.java, "$key: $value") + } + } + + @JvmRecord + data class CachedStatementStats(val created: Instant, val lastAccessed: Instant, val count: Int) +} diff --git a/src/main/java/core/jmx/StatementCacheMonitor.kt b/src/main/java/core/jmx/StatementCacheMonitor.kt new file mode 100644 index 000000000..694223766 --- /dev/null +++ b/src/main/java/core/jmx/StatementCacheMonitor.kt @@ -0,0 +1,22 @@ +package core.jmx + +import core.db.DBManager + +class StatementCacheMonitor: StatementCacheMonitorMBean { + override fun getStatistics(): Map { + val connectionManager = DBManager.instance().connectionManager + return connectionManager.statementCache.statementStats.map { + entry -> entry.key to entry.value.toString() + }.toMap() + } + + override fun getCachedStatementCount(): Int { + val connectionManager = DBManager.instance().connectionManager + return connectionManager.statementCache.statementStats.size + } + + override fun setCacheEnabled(enabled: Boolean) { + val connectionManager = DBManager.instance().connectionManager + connectionManager.statementCache.cachedEnabled = enabled + } +} diff --git a/src/main/java/core/jmx/StatementCacheMonitorMBean.kt b/src/main/java/core/jmx/StatementCacheMonitorMBean.kt new file mode 100644 index 000000000..aa0133733 --- /dev/null +++ b/src/main/java/core/jmx/StatementCacheMonitorMBean.kt @@ -0,0 +1,10 @@ +package core.jmx + +import core.db.StatementCache + +interface StatementCacheMonitorMBean { + fun getStatistics():Map + fun getCachedStatementCount(): Int + + fun setCacheEnabled(enabled: Boolean) +} diff --git a/src/main/java/module/lineup/Lineup.java b/src/main/java/module/lineup/Lineup.java index f19c0be02..aaef88d03 100644 --- a/src/main/java/module/lineup/Lineup.java +++ b/src/main/java/module/lineup/Lineup.java @@ -1170,7 +1170,7 @@ public final void checkAufgestellteSpieler() { } /** - * Assitant to create automatically the lineup + * Assistant to create automatically the lineup */ public final void optimizeLineup(List players, byte sectorsStrengthPriority, boolean withForm, boolean idealPosFirst, boolean considerInjured, boolean considereSuspended) { @@ -1180,32 +1180,6 @@ public final void optimizeLineup(List players, byte sectorsStrengthPrior setAutoKapitaen(null); } -// /** -// * Clone this lineup, creates and returns a new Lineup object. -// */ -// public final @NotNull Lineup duplicate() { -// -// Lineup clone = new Lineup(); -// clone.setPenaltyTakers(getPenaltyTakers()); -// clone.setLocation(getLocation()); -// clone.setPullBackMinute(getPullBackMinute()); -// clone.setWeather(getWeather()); -// clone.setWeatherForecast(getWeatherForecast()); -// clone.setArenaId(getArenaId()); -// clone.setRegionId(getRegionId()); -// -// clone.m_vFieldPositions = copyPositions(m_vFieldPositions); -// clone.m_vBenchPositions = copyPositions(m_vBenchPositions); -// clone.setKicker(this.getKicker()); -// clone.setCaptain(this.getCaptain()); -// clone.setTacticType(this.getTacticType()); -// clone.setAttitude(this.getAttitude()); -// clone.setStyleOfPlay(this.getCoachModifier()); -// -// clone.substitutions = copySubstitutions(); -// return clone; -// } - public final String getCurrentTeamFormationString() { final int iNbDefs = getNbDefenders(); final int iNbMids = getNbMidfields(); diff --git a/src/main/java/module/lineup/assistant/LineupAssistant.java b/src/main/java/module/lineup/assistant/LineupAssistant.java index 3b9321985..9919248ba 100644 --- a/src/main/java/module/lineup/assistant/LineupAssistant.java +++ b/src/main/java/module/lineup/assistant/LineupAssistant.java @@ -52,7 +52,7 @@ public final boolean isPlayerInStartingEleven(int spielerId, Vector entry : positions - .entrySet()) { + for (Map.Entry entry : positions.entrySet()) { if (entry.getValue() == null) { boolean selected = true; LineupAssistantSelectorOverlay laso = new LineupAssistantSelectorOverlay(); diff --git a/src/main/java/module/teamAnalyzer/SystemManager.java b/src/main/java/module/teamAnalyzer/SystemManager.java index ed0b7c3f9..b8777e89c 100644 --- a/src/main/java/module/teamAnalyzer/SystemManager.java +++ b/src/main/java/module/teamAnalyzer/SystemManager.java @@ -1,4 +1,3 @@ -// %2940960156:hoplugins.teamAnalyzer% package module.teamAnalyzer; import core.module.config.ModuleConfig; @@ -28,11 +27,6 @@ public class SystemManager { private final static String ISDESCRIPTIONRATING = "TA_descriptionRating"; private final static String ISSHOWUNAVAILABLE = "TA_isShowUnavailable"; private final static String ISMIXEDLINEUP = "TA_mixedLineup"; -// private final static String ISSTARS = "TA_isStars"; -// private final static String ISTOTALSTRENGTH = "TA_isTotalStrength"; -// private final static String ISSQUAD = "TA_isSquad"; -// private final static String ISSMARTSQUAD = "TA_isSmartSquad"; -// private final static String ISLODDARSTATS = "TA_isLoddarStats"; private final static String ISSHOWPLAYERINFO = "TA_isShowPlayerInfo"; private final static String ISCHECKTEAMNAME = "TA_isCheckTeamName"; @@ -70,11 +64,6 @@ public void set(boolean selected) { public static Setting isDescriptionRating = new Setting(ISDESCRIPTIONRATING); public static Setting isShowUnavailable = new Setting(ISSHOWUNAVAILABLE); public static Setting isMixedLineup = new Setting(ISMIXEDLINEUP, false); -// public static Setting isStars = new Setting(ISSTARS); -// public static Setting isTotalStrength = new Setting(ISTOTALSTRENGTH); -// public static Setting isSquad = new Setting(ISSQUAD); -// public static Setting isSmartSquad = new Setting(ISSMARTSQUAD); -// public static Setting isLoddarStats = new Setting(ISLODDARSTATS); public static Setting isShowPlayerInfo = new Setting(ISSHOWPLAYERINFO, false); public static Setting isCheckTeamName = new Setting(ISCHECKTEAMNAME); @@ -104,7 +93,7 @@ public static void setActiveTeam(Team team) { * @return int */ public static int getActiveTeamId() { - if ( selectedTeam == null){ + if (selectedTeam == null) { selectedTeam = TeamManager.getFirstTeam(); } return selectedTeam.getTeamId(); @@ -147,7 +136,7 @@ public static void refresh() { NameManager.clean(); TeamAnalyzerPanel.filter.setMatches(new ArrayList<>()); - teamReport = null; //ReportManager.clean(); + teamReport = null; MatchPopulator.clean(); MatchManager.clean(); plugin.getMainPanel().reload(null, 0, 0); @@ -174,8 +163,8 @@ public static void refreshData() { public static void updateReport() { updating = true; List matchDetails = MatchManager.getMatchDetails(); - if (MatchPopulator.getAnalyzedMatch().size() > 0) { - teamReport = new TeamReport(getActiveTeamId(), matchDetails); + if (!MatchPopulator.getAnalyzedMatch().isEmpty()) { + teamReport = new TeamReport(getActiveTeamId(), matchDetails); } else { teamReport = null; } diff --git a/src/main/java/module/teamAnalyzer/TeamAnalyzerModule.java b/src/main/java/module/teamAnalyzer/TeamAnalyzerModule.java index 78a78ec44..256e35e50 100644 --- a/src/main/java/module/teamAnalyzer/TeamAnalyzerModule.java +++ b/src/main/java/module/teamAnalyzer/TeamAnalyzerModule.java @@ -15,7 +15,7 @@ public final class TeamAnalyzerModule extends DefaultModule { - private TeamAnalyzerPanel teamAnalyzerPanel=null; + private TeamAnalyzerPanel teamAnalyzerPanel = null; public TeamAnalyzerModule(){ super(true); diff --git a/src/main/java/module/teamAnalyzer/ht/HattrickManager.java b/src/main/java/module/teamAnalyzer/ht/HattrickManager.java index 572bec117..e901862b9 100644 --- a/src/main/java/module/teamAnalyzer/ht/HattrickManager.java +++ b/src/main/java/module/teamAnalyzer/ht/HattrickManager.java @@ -1,4 +1,3 @@ -// %1667190662:hoplugins.teamAnalyzer.ht% package module.teamAnalyzer.ht; import core.db.DBManager; diff --git a/src/main/java/module/teamAnalyzer/ui/RatingUtil.java b/src/main/java/module/teamAnalyzer/ui/RatingUtil.java index b80eb2839..3918a3ac2 100644 --- a/src/main/java/module/teamAnalyzer/ui/RatingUtil.java +++ b/src/main/java/module/teamAnalyzer/ui/RatingUtil.java @@ -15,7 +15,7 @@ */ public final class RatingUtil { /** - * Private default constuctor to prevent class instantiation. + * Private default constructor to prevent class instantiation. */ private RatingUtil() { } @@ -74,7 +74,7 @@ else if (subLevel.contains(HOVerwaltung.instance().getLanguageString("low"))) { final String number = st2.nextToken(); - if (level.length() > 0) { + if (!level.isEmpty()) { level = level + " (" + number + ")"; } else { diff --git a/src/test/java/core/db/BasicsTableTest.kt b/src/test/java/core/db/BasicsTableTest.kt index d09368aa5..9ab29527f 100644 --- a/src/test/java/core/db/BasicsTableTest.kt +++ b/src/test/java/core/db/BasicsTableTest.kt @@ -1,10 +1,7 @@ package core.db import core.model.misc.Basics -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* import java.sql.Connection import java.sql.DriverManager import java.sql.ResultSet @@ -48,4 +45,13 @@ internal class BasicsTableTest { rs?.close() stmt.close() } -} \ No newline at end of file + + companion object { + @AfterAll + @JvmStatic + fun cleanUp() { + val conn = DriverManager.getConnection("jdbc:hsqldb:mem:testdb", "SA", "") + conn.createStatement().execute("DROP TABLE BASICS") + } + } +} diff --git a/src/test/java/core/db/DBInfoTest.kt b/src/test/java/core/db/DBInfoTest.kt index 753e2710e..ccfb52bb9 100644 --- a/src/test/java/core/db/DBInfoTest.kt +++ b/src/test/java/core/db/DBInfoTest.kt @@ -1,5 +1,6 @@ package core.db +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.sql.DriverManager @@ -39,4 +40,13 @@ internal class DBInfoTest { conn?.close() } + + companion object { + @AfterAll + @JvmStatic + fun cleanUp() { + val conn = DriverManager.getConnection("jdbc:hsqldb:mem:testdb", "SA", "") + conn.createStatement().execute("DROP TABLE TEST") + } + } } diff --git a/src/test/java/core/db/StatementCacheTest.kt b/src/test/java/core/db/StatementCacheTest.kt new file mode 100644 index 000000000..dfff484e5 --- /dev/null +++ b/src/test/java/core/db/StatementCacheTest.kt @@ -0,0 +1,85 @@ +package core.db + +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestInstance +import java.sql.Connection +import java.sql.DriverManager +import java.time.Instant +import java.time.temporal.ChronoUnit + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StatementCacheTest { + private lateinit var conn: Connection + private lateinit var statementCache: StatementCache + + @BeforeAll + fun setUp() { + conn = DriverManager.getConnection("jdbc:hsqldb:mem:testdb", "SA", "") + val connectionManager = ConnectionManager() + connectionManager.connect(conn) + conn.createStatement().execute("CREATE TABLE TEST (ID INT PRIMARY KEY, CONTENT VARCHAR(255))"); + + statementCache = StatementCache(connectionManager) + } + + @Test + fun testGetPreparedStatementRetrievesStatementFromCacheWhenEnabled() { + statementCache.cachedEnabled = true + val stmt = statementCache.getPreparedStatement("INSERT INTO TEST (ID, CONTENT) VALUES (?, ?)") + val stats = statementCache.statementStats + assertEquals(1, stats.count()) + + val otherStmt = statementCache.getPreparedStatement("INSERT INTO TEST (ID, CONTENT) VALUES (?, ?)") + assertTrue(stmt == otherStmt) + assertEquals(1, stats.count()) + } + + @Test + fun testGetPreparedStatementCreatesNewStatementWhenCacheNotEnabled() { + statementCache.cachedEnabled = false + val stmt = statementCache.getPreparedStatement("INSERT INTO TEST (ID, CONTENT) VALUES (?, ?)") + val stats = statementCache.statementStats + assertEquals(0, stats.count()) + + val otherStmt = statementCache.getPreparedStatement("INSERT INTO TEST (ID, CONTENT) VALUES (?, ?)") + assertTrue(stmt != otherStmt) + assertEquals(0, stats.count()) + } + + @Test + fun testCacheGetsClearedWhenDisablingIt() { + statementCache.cachedEnabled = true + statementCache.getPreparedStatement("INSERT INTO TEST (ID, CONTENT) VALUES (?, ?)") + val stats = statementCache.statementStats + assertEquals(1, stats.count()) + + statementCache.cachedEnabled = false + assertEquals(0, stats.count()) + } + + @Test + fun testStatsTrackDetailsAboutStatements() { + val stmt = "INSERT INTO TEST (ID, CONTENT) VALUES (?, ?)" + statementCache.cachedEnabled = true + statementCache.getPreparedStatement(stmt) + val stats = statementCache.statementStats + assertEquals(1, stats.count()) + + val rec = statementCache.statementStats[stmt] + assertTrue(ChronoUnit.SECONDS.between(rec!!.created, Instant.now()) < 1) + assertTrue(ChronoUnit.SECONDS.between(rec.lastAccessed, Instant.now()) < 1) + } + + companion object { + @AfterAll + @JvmStatic + fun cleanUp() { + val conn = DriverManager.getConnection("jdbc:hsqldb:mem:testdb", "SA", "") + conn.createStatement().execute("DROP TABLE TEST") + } + } +}