mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-10 19:25:32 +02:00
Make the terrain profile cache multi operator safe
The cache stored a single owner identity in a meta table and dropped the whole TerrainProfileCache table whenever the configured callsign or locator differed from it. With several operator profiles that turns the cache into a permanent miss: every switch between two operators with different locators would discard every computed profile. Entries are separated by owner identity through the primary key already, so the wipe is replaced by an owner table that simply records which identities are in use. A different owner now misses the cache instead of clearing everybody's. The cache also moves out of the worked station database into its own global terrainprofilecache.db. Terrain profiles are pure geometry derived from two locators and a sample count; at a multi operator station both operators share one location, so a per profile copy would only double the traffic against the terrain service. No migration is needed, the new file refills itself, and the old tables stay readable for older releases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
This commit is contained in:
@@ -135,6 +135,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
|||||||
### Terrain data providers
|
### Terrain data providers
|
||||||
|
|
||||||
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
|
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
|
||||||
|
- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service.
|
||||||
|
- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself.
|
||||||
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
|
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
|
||||||
|
|
||||||
### ON4KST session and authentication
|
### ON4KST session and authentication
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
|
|||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
|
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
|
||||||
|
|
||||||
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
|
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -891,6 +891,8 @@ Mehrere Operateure an einem Rechner brauchen unterschiedliche Rufzeichen, Locato
|
|||||||
|
|
||||||
Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`.
|
Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`.
|
||||||
|
|
||||||
|
Alle übrigen Daten bleiben gemeinsam: Klangdateien, Farbschemata, DEM- und Terrainpakete, der Zwischenspeicher der Geländeprofile (`terrainprofilecache.db`), das Fehlerprotokoll und die Versionsinformationen.
|
||||||
|
|
||||||
Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor.
|
Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor.
|
||||||
|
|
||||||
### Gemeinsame oder eigene gearbeitete Stationen
|
### Gemeinsame oder eigene gearbeitete Stationen
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ Several operators sharing one computer can now use their own callsigns, locators
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
|
||||||
|
|
||||||
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
|
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -948,6 +948,8 @@ Several operators sharing one computer need different callsigns, locators and la
|
|||||||
|
|
||||||
On Windows the same files live below `%USERPROFILE%\.praktiKST\`.
|
On Windows the same files live below `%USERPROFILE%\.praktiKST\`.
|
||||||
|
|
||||||
|
Everything else stays shared: audio files, colour schemes, DEM and terrain packages, the terrain profile cache (`terrainprofilecache.db`), the error log and the version information.
|
||||||
|
|
||||||
The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged.
|
The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged.
|
||||||
|
|
||||||
### Shared or own worked stations
|
### Shared or own worked stations
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package kst4contest.view.map;
|
package kst4contest.view.map;
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
import kst4contest.ApplicationConstants;
|
||||||
import kst4contest.controller.DBController;
|
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
import kst4contest.utils.ApplicationFileUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
import java.sql.DriverManager;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
@@ -15,30 +17,44 @@ import java.util.Locale;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent terrain profile cache stored in the application's existing SQLite database.
|
* Persistent terrain profile cache, stored globally in its own SQLite database.
|
||||||
*
|
*
|
||||||
* The cache is intentionally owner-bound:
|
* <p>Terrain profiles are pure geometry derived from two locators and a sample count.
|
||||||
* if the configured own callsign or own locator changes, all cached terrain
|
* They do not belong to one operator, so the cache is deliberately not part of an
|
||||||
* profiles are cleared automatically.
|
* operator profile: at a multi operator station both operators share one location, and
|
||||||
|
* duplicating the cache would double the traffic against an external terrain service.</p>
|
||||||
|
*
|
||||||
|
* <p>Entries are separated by owner identity through the primary key instead. Earlier
|
||||||
|
* versions kept a single owner identity and dropped the whole cache whenever the
|
||||||
|
* configured callsign or locator changed; with several operator profiles that would
|
||||||
|
* discard every computed profile on each switch.</p>
|
||||||
*/
|
*/
|
||||||
public final class TerrainProfileCacheRepository {
|
public final class TerrainProfileCacheRepository {
|
||||||
|
|
||||||
private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw";
|
/**
|
||||||
private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6";
|
* File name of the global terrain profile cache below the application directory.
|
||||||
|
*/
|
||||||
|
public static final String TERRAIN_CACHE_DATABASE_FILE = "terrainprofilecache.db";
|
||||||
|
|
||||||
private final String databasePath;
|
private final String databasePath;
|
||||||
|
|
||||||
public TerrainProfileCacheRepository() {
|
public TerrainProfileCacheRepository() {
|
||||||
ApplicationFileUtils.copyResourceIfRequired(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
DBController.DATABASE_RESOURCE,
|
|
||||||
DBController.DATABASE_FILE
|
|
||||||
);
|
|
||||||
|
|
||||||
this.databasePath = ApplicationFileUtils.getFilePath(
|
this.databasePath = ApplicationFileUtils.getFilePath(
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
ApplicationConstants.APPLICATION_NAME,
|
||||||
DBController.DATABASE_FILE
|
TERRAIN_CACHE_DATABASE_FILE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// SQLite only creates the database file itself, not the directory holding it.
|
||||||
|
Path applicationDirectory = Path.of(databasePath).getParent();
|
||||||
|
|
||||||
|
if (applicationDirectory != null) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(applicationDirectory);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
System.err.println("[StationMap] Terrain cache directory could not be created: "
|
||||||
|
+ exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
|
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
|
||||||
@@ -183,60 +199,37 @@ public final class TerrainProfileCacheRepository {
|
|||||||
""");
|
""");
|
||||||
|
|
||||||
statement.executeUpdate("""
|
statement.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta (
|
CREATE TABLE IF NOT EXISTS TerrainProfileCacheOwner (
|
||||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
owner_callsign_raw TEXT NOT NULL,
|
||||||
meta_value TEXT NOT NULL
|
owner_locator6 TEXT NOT NULL,
|
||||||
|
last_used_epoch_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (owner_callsign_raw, owner_locator6)
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the given owner identity is in use.
|
||||||
|
*
|
||||||
|
* <p>Entries of other owners stay untouched. The cached profiles of an identity are
|
||||||
|
* separated by the primary key already, so a different callsign or locator simply
|
||||||
|
* misses the cache instead of invalidating everybody else's entries.</p>
|
||||||
|
*/
|
||||||
private void ensureOwnerIdentity(Connection connection,
|
private void ensureOwnerIdentity(Connection connection,
|
||||||
String currentOwnerCallsignRaw,
|
String currentOwnerCallsignRaw,
|
||||||
String currentOwnerLocator6) throws Exception {
|
String currentOwnerLocator6) throws Exception {
|
||||||
|
|
||||||
String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw);
|
|
||||||
String normalizedOwnerLocator6 = normalize(currentOwnerLocator6);
|
|
||||||
|
|
||||||
String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW);
|
|
||||||
String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6);
|
|
||||||
|
|
||||||
boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw);
|
|
||||||
boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6);
|
|
||||||
|
|
||||||
if (callsignChanged || locatorChanged) {
|
|
||||||
clearTerrainCache(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw);
|
|
||||||
writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void clearTerrainCache(Connection connection) throws Exception {
|
|
||||||
try (Statement statement = connection.createStatement()) {
|
|
||||||
statement.executeUpdate("DELETE FROM TerrainProfileCache");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String readMetaValue(Connection connection, String key) throws Exception {
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement(
|
|
||||||
"SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) {
|
|
||||||
statement.setString(1, key);
|
|
||||||
|
|
||||||
try (ResultSet resultSet = statement.executeQuery()) {
|
|
||||||
return resultSet.next() ? resultSet.getString(1) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeMetaValue(Connection connection, String key, String value) throws Exception {
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
try (PreparedStatement statement = connection.prepareStatement("""
|
||||||
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value)
|
INSERT INTO TerrainProfileCacheOwner (
|
||||||
VALUES (?, ?)
|
owner_callsign_raw, owner_locator6, last_used_epoch_ms
|
||||||
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value
|
) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(owner_callsign_raw, owner_locator6) DO UPDATE SET
|
||||||
|
last_used_epoch_ms = excluded.last_used_epoch_ms
|
||||||
""")) {
|
""")) {
|
||||||
statement.setString(1, key);
|
statement.setString(1, normalize(currentOwnerCallsignRaw));
|
||||||
statement.setString(2, value == null ? "" : value);
|
statement.setString(2, normalize(currentOwnerLocator6));
|
||||||
|
statement.setLong(3, System.currentTimeMillis());
|
||||||
statement.executeUpdate();
|
statement.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package kst4contest.view.map;
|
||||||
|
|
||||||
|
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.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the property that made the terrain cache useless with several operator
|
||||||
|
* profiles: it used to drop every cached profile whenever the configured callsign or
|
||||||
|
* locator changed.
|
||||||
|
*/
|
||||||
|
class TerrainProfileCacheRepositoryTest {
|
||||||
|
|
||||||
|
private static final String USER_HOME_PROPERTY = "user.home";
|
||||||
|
private static final String PROVIDER_ID = "test-provider";
|
||||||
|
private static final int SAMPLE_COUNT = 3;
|
||||||
|
|
||||||
|
@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 profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() {
|
||||||
|
|
||||||
|
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||||
|
|
||||||
|
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||||
|
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||||
|
|
||||||
|
// Another operator profile with a different callsign and locator.
|
||||||
|
repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK",
|
||||||
|
SAMPLE_COUNT, PROVIDER_ID, profileData("station-b"));
|
||||||
|
|
||||||
|
Optional<TerrainProfileData> firstOwnerEntry = repository.load(
|
||||||
|
"DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
|
||||||
|
Optional<TerrainProfileData> secondOwnerEntry = repository.load(
|
||||||
|
"DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
|
||||||
|
|
||||||
|
assertTrue(firstOwnerEntry.isPresent(),
|
||||||
|
"Working under a second operator identity must not discard the first one's cache");
|
||||||
|
assertTrue(secondOwnerEntry.isPresent());
|
||||||
|
assertEquals("station-a", firstOwnerEntry.get().sourceName());
|
||||||
|
assertEquals("station-b", secondOwnerEntry.get().sourceName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() {
|
||||||
|
|
||||||
|
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||||
|
|
||||||
|
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||||
|
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||||
|
|
||||||
|
assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK",
|
||||||
|
SAMPLE_COUNT, PROVIDER_ID).isEmpty());
|
||||||
|
|
||||||
|
assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||||
|
SAMPLE_COUNT, PROVIDER_ID).isPresent(),
|
||||||
|
"A cache miss of one owner must not remove the entries of another");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() {
|
||||||
|
|
||||||
|
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
|
||||||
|
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
|
||||||
|
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
|
||||||
|
|
||||||
|
Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST");
|
||||||
|
|
||||||
|
assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db")));
|
||||||
|
assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")),
|
||||||
|
"The terrain cache must not pull in the worked station database");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TerrainProfileData profileData(final String sourceName) {
|
||||||
|
return new TerrainProfileData(
|
||||||
|
List.of(
|
||||||
|
new PathProfilePoint(0.0, 51.0, 10.0, 100.0),
|
||||||
|
new PathProfilePoint(10.0, 51.1, 10.1, 220.0),
|
||||||
|
new PathProfilePoint(20.0, 51.2, 10.2, 150.0)),
|
||||||
|
sourceName,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user