mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-11 19:55:40 +02:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
This commit is contained in:
@@ -6,9 +6,13 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Statement;
|
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.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
import kst4contest.ApplicationConstants;
|
||||||
import kst4contest.model.ChatMember;
|
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 long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L;
|
||||||
|
|
||||||
private static final DBController dbcontroller = new DBController();
|
/**
|
||||||
private static Connection connection;
|
* Lazily created controller for the root installation database. It is created on
|
||||||
private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
* 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.
|
* Remembers the last timestamp at which the expiration cleanup had been executed.
|
||||||
*/
|
*/
|
||||||
private long lastExpirationCleanupExecutionEpochMs = 0L;
|
private long lastExpirationCleanupExecutionEpochMs = 0L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a controller for the worked-station database of the root installation.
|
||||||
|
*/
|
||||||
public DBController() {
|
public DBController() {
|
||||||
initDBConnection();
|
this(DATABASE_FILE, true);
|
||||||
}
|
|
||||||
|
|
||||||
public static DBController getInstance() {
|
|
||||||
return dbcontroller;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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() {
|
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 {
|
try {
|
||||||
if (connection != null && !connection.isClosed()) {
|
if (connection != null && !connection.isClosed()) {
|
||||||
connection.close();
|
connection.close();
|
||||||
|
System.out.println("Connection to Database closed: " + databaseFilePath);
|
||||||
}
|
}
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,22 +189,17 @@ public class DBController {
|
|||||||
System.out.println("DBH: initiate new db connection");
|
System.out.println("DBH: initiate new db connection");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ApplicationFileUtils.copyResourceIfRequired(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
DATABASE_RESOURCE,
|
|
||||||
DATABASE_FILE
|
|
||||||
);
|
|
||||||
|
|
||||||
if (connection != null && !connection.isClosed()) {
|
if (connection != null && !connection.isClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareDatabaseFile();
|
||||||
|
|
||||||
System.out.println("Creating Connection to Database...");
|
System.out.println("Creating Connection to Database...");
|
||||||
|
|
||||||
DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE);
|
connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath);
|
||||||
connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH);
|
|
||||||
|
|
||||||
System.out.println("[DBH, Info]: Path = " + DB_PATH);
|
System.out.println("[DBH, Info]: Path = " + databaseFilePath);
|
||||||
|
|
||||||
if (!connection.isClosed()) {
|
if (!connection.isClosed()) {
|
||||||
System.out.println("...Connection established");
|
System.out.println("...Connection established");
|
||||||
@@ -115,25 +208,50 @@ public class DBController {
|
|||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread() {
|
databaseShutdownHook = new Thread(this::closeConnectionQuietly,
|
||||||
public void run() {
|
"DBController-shutdown-" + databaseRelativeFileName);
|
||||||
try {
|
Runtime.getRuntime().addShutdownHook(databaseShutdownHook);
|
||||||
if (connection != null && !connection.isClosed()) {
|
|
||||||
connection.close();
|
|
||||||
|
|
||||||
if (connection.isClosed()) {
|
|
||||||
System.out.println("Connection to Database closed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (SQLException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ensureChatMemberTableCompatibility();
|
ensureChatMemberTableCompatibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure the database file can be opened.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
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
|
* Ensures that the ChatMember table exists, that all required columns are
|
||||||
* available for newer software versions, that existing old callsign keys are
|
* available for newer software versions, that existing old callsign keys are
|
||||||
|
|||||||
@@ -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<String, ChatMember> firstProfileContent =
|
||||||
|
firstProfileDatabase.fetchChatMemberWkdDataFromDB();
|
||||||
|
Map<String, ChatMember> 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<String, ChatMember> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user