From 681419f1696ab1c9651c69a0637e5cfb611d4d84 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 07:36:27 +0000 Subject: [PATCH] Make DBController work on one database file per instance The controller was a static singleton: an eagerly created static instance opened the root database during class initialization, and both the connection and the path were static fields. A second operator profile in the same process was therefore impossible, and ChatController's own "new DBController()" never opened anything - it silently adopted the eagerly opened root connection. - drop the eager static instance in favour of a lazily created default instance - turn connection and path into instance state - add a constructor taking a database file name relative to the application directory plus a flag whether a missing file is seeded from the bundled template - create additional profile databases empty instead of seeding them: the bundled template carries 3452 foreign callsigns and user_version 0, which would show a new operator foreign data and trigger the full callsign normalization rebuild. The schema is created by the existing table setup. - remember the shutdown hook so closeDBConnection can deregister it; otherwise every profile switch would leave another hook holding a dead connection All SQL statements keep referencing the plain field and are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm --- .../kst4contest/controller/DBController.java | 184 ++++++++++++++---- .../DBControllerProfileDatabaseTest.java | 127 ++++++++++++ 2 files changed, 278 insertions(+), 33 deletions(-) create mode 100644 src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java diff --git a/src/main/java/kst4contest/controller/DBController.java b/src/main/java/kst4contest/controller/DBController.java index 3c32db5d..9409c4cb 100644 --- a/src/main/java/kst4contest/controller/DBController.java +++ b/src/main/java/kst4contest/controller/DBController.java @@ -6,9 +6,13 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import kst4contest.ApplicationConstants; import kst4contest.model.ChatMember; @@ -52,34 +56,128 @@ public class DBController { */ private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L; - private static final DBController dbcontroller = new DBController(); - private static Connection connection; - private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); + /** + * Lazily created controller for the root installation database. It is created on + * first use only, because an eagerly created instance would open a database file + * before the application knows which operator profile is active. + */ + private static volatile DBController defaultInstance; + + private Connection connection; + + /** + * File name of this database relative to the application directory, for example + * "praktiKST.db" or "profiles/OP2/praktiKST.db". + */ + private final String databaseRelativeFileName; + + /** + * Absolute path of the database file, resolved once during construction. + */ + private final String databaseFilePath; + + /** + * True if a missing database file should be seeded from the shipped template. + */ + private final boolean seedFromResource; + + /** + * Shutdown hook of this instance. It is remembered so it can be deregistered when + * the connection is closed. Without that, every operator profile switch would leave + * another hook behind that keeps a dead connection alive until the process ends. + */ + private Thread databaseShutdownHook; /** * Remembers the last timestamp at which the expiration cleanup had been executed. */ private long lastExpirationCleanupExecutionEpochMs = 0L; + /** + * Creates a controller for the worked-station database of the root installation. + */ public DBController() { - initDBConnection(); - } - - public static DBController getInstance() { - return dbcontroller; + this(DATABASE_FILE, true); } /** - * Closes the database connection if it is still open. + * Creates a controller for the worked-station database of one operator profile. + * + * @param databaseRelativeFileName file name relative to the application directory + * @param seedFromResource true to copy the shipped template database when the file + * does not exist yet, false to create an empty database and + * let the schema creation build all required tables + */ + public DBController(final String databaseRelativeFileName, final boolean seedFromResource) { + this.databaseRelativeFileName = + Objects.requireNonNull(databaseRelativeFileName, "databaseRelativeFileName"); + this.seedFromResource = seedFromResource; + this.databaseFilePath = ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + databaseRelativeFileName + ); + + initDBConnection(); + } + + /** + * Returns a controller for the root installation database, creating it on first use. + * + * @return the shared controller for the root installation database + */ + public static synchronized DBController getInstance() { + + if (defaultInstance == null) { + defaultInstance = new DBController(); + } + + return defaultInstance; + } + + /** + * Returns the absolute path of the database file this controller works on. + * + * @return absolute database file path + */ + public String getDatabaseFilePath() { + return databaseFilePath; + } + + /** + * Closes the database connection if it is still open and deregisters the shutdown + * hook of this instance. */ public synchronized void closeDBConnection() { + + closeConnectionQuietly(); + + if (databaseShutdownHook != null) { + try { + Runtime.getRuntime().removeShutdownHook(databaseShutdownHook); + } catch (IllegalStateException shutdownAlreadyInProgress) { + // Expected while the JVM is shutting down; the hook is running anyway. + } + + databaseShutdownHook = null; + } + } + + /** + * Closes the connection without touching the shutdown hook. This is also the body of + * the shutdown hook itself. + */ + private synchronized void closeConnectionQuietly() { + try { if (connection != null && !connection.isClosed()) { connection.close(); + System.out.println("Connection to Database closed: " + databaseFilePath); } } catch (SQLException e) { e.printStackTrace(); } + + connection = null; } /** @@ -91,22 +189,17 @@ public class DBController { System.out.println("DBH: initiate new db connection"); try { - ApplicationFileUtils.copyResourceIfRequired( - ApplicationConstants.APPLICATION_NAME, - DATABASE_RESOURCE, - DATABASE_FILE - ); - if (connection != null && !connection.isClosed()) { return; } + prepareDatabaseFile(); + System.out.println("Creating Connection to Database..."); - DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); - connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH); + connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath); - System.out.println("[DBH, Info]: Path = " + DB_PATH); + System.out.println("[DBH, Info]: Path = " + databaseFilePath); if (!connection.isClosed()) { System.out.println("...Connection established"); @@ -115,25 +208,50 @@ public class DBController { throw new RuntimeException(e); } - Runtime.getRuntime().addShutdownHook(new Thread() { - public void run() { - try { - if (connection != null && !connection.isClosed()) { - connection.close(); - - if (connection.isClosed()) { - System.out.println("Connection to Database closed"); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - } - }); + databaseShutdownHook = new Thread(this::closeConnectionQuietly, + "DBController-shutdown-" + databaseRelativeFileName); + Runtime.getRuntime().addShutdownHook(databaseShutdownHook); ensureChatMemberTableCompatibility(); } + /** + * Makes sure the database file can be opened. + * + *

The database of the root installation is seeded from the shipped template so + * existing installations keep their historic content. A database that belongs to an + * additional operator profile is created empty on purpose: the shipped template + * carries several thousand foreign callsigns and an outdated schema version, which + * would present a new operator with foreign data and trigger the full callsign + * normalization rebuild. The required tables are created by + * {@link #ensureChatMemberTableCompatibility()} in both cases.

+ */ + private synchronized void prepareDatabaseFile() { + + if (seedFromResource) { + ApplicationFileUtils.copyResourceIfRequired( + ApplicationConstants.APPLICATION_NAME, + DATABASE_RESOURCE, + databaseRelativeFileName + ); + + return; + } + + Path parentDirectory = Path.of(databaseFilePath).getParent(); + + if (parentDirectory == null) { + return; + } + + try { + Files.createDirectories(parentDirectory); + } catch (IOException e) { + throw new RuntimeException( + "[DBH, ERROR:] Could not create database directory " + parentDirectory, e); + } + } + /** * Ensures that the ChatMember table exists, that all required columns are * available for newer software versions, that existing old callsign keys are diff --git a/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java b/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java new file mode 100644 index 00000000..cf237885 --- /dev/null +++ b/src/test/java/kst4contest/controller/DBControllerProfileDatabaseTest.java @@ -0,0 +1,127 @@ +package kst4contest.controller; + +import kst4contest.model.ChatMember; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that one DBController instance works on exactly one database file, so two + * operator profiles can keep independent worked-station data inside the same process. + */ +class DBControllerProfileDatabaseTest { + + private static final String USER_HOME_PROPERTY = "user.home"; + + @TempDir + Path temporaryHomeDirectory; + + private String originalUserHome; + + @BeforeEach + void redirectUserHomeToTemporaryDirectory() { + originalUserHome = System.getProperty(USER_HOME_PROPERTY); + System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString()); + } + + @AfterEach + void restoreUserHome() { + if (originalUserHome == null) { + System.clearProperty(USER_HOME_PROPERTY); + } else { + System.setProperty(USER_HOME_PROPERTY, originalUserHome); + } + } + + @Test + void profileDatabaseIsCreatedEmptyAndKeepsWorkedDataSeparate() throws SQLException { + + DBController firstProfileDatabase = + new DBController("profiles/OP1/praktiKST.db", false); + DBController secondProfileDatabase = + new DBController("profiles/OP2/praktiKST.db", false); + + try { + // A profile database must not inherit the several thousand callsigns of the + // bundled template database. + assertTrue(firstProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty()); + assertTrue(secondProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty()); + + assertNotEquals(firstProfileDatabase.getDatabaseFilePath(), + secondProfileDatabase.getDatabaseFilePath()); + assertTrue(Files.exists(Path.of(firstProfileDatabase.getDatabaseFilePath()))); + assertTrue(Files.exists(Path.of(secondProfileDatabase.getDatabaseFilePath()))); + + ChatMember workedOnFirstProfile = new ChatMember(); + workedOnFirstProfile.setCallSign("DL0XYZ"); + workedOnFirstProfile.setQra("JO51IJ"); + workedOnFirstProfile.setWorked(true); + workedOnFirstProfile.setWorked144(true); + + firstProfileDatabase.storeChatMember(workedOnFirstProfile); + + Map firstProfileContent = + firstProfileDatabase.fetchChatMemberWkdDataFromDB(); + Map secondProfileContent = + secondProfileDatabase.fetchChatMemberWkdDataFromDB(); + + assertEquals(1, firstProfileContent.size()); + assertTrue(firstProfileContent.get("DL0XYZ").isWorked144()); + assertTrue(secondProfileContent.isEmpty(), + "A worked station of one profile must not appear in the other profile"); + } finally { + firstProfileDatabase.closeDBConnection(); + secondProfileDatabase.closeDBConnection(); + } + } + + @Test + void twoProfilesPointingAtTheSameFileShareTheirWorkedData() throws SQLException { + + DBController sharedStationDatabase = new DBController("praktiKST.db", false); + DBController sameSharedDatabaseAgain = new DBController("praktiKST.db", false); + + try { + ChatMember workedAtTheStation = new ChatMember(); + workedAtTheStation.setCallSign("DL0ABC"); + workedAtTheStation.setWorked(true); + workedAtTheStation.setWorked432(true); + + sharedStationDatabase.storeChatMember(workedAtTheStation); + + Map seenByTheOtherOperator = + sameSharedDatabaseAgain.fetchChatMemberWkdDataFromDB(); + + assertTrue(seenByTheOtherOperator.containsKey("DL0ABC"), + "Operators sharing one station database must see the same worked stations"); + assertTrue(seenByTheOtherOperator.get("DL0ABC").isWorked432()); + } finally { + sharedStationDatabase.closeDBConnection(); + sameSharedDatabaseAgain.closeDBConnection(); + } + } + + @Test + void closingTheConnectionDeregistersTheShutdownHook() { + + DBController profileDatabase = new DBController("profiles/OP3/praktiKST.db", false); + profileDatabase.closeDBConnection(); + + // A second close must stay harmless, and the hook must already be gone. + profileDatabase.closeDBConnection(); + + assertFalse(Files.notExists(Path.of(profileDatabase.getDatabaseFilePath())), + "The database file stays on disk after the connection was closed"); + } +}