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:
Rsclub2_2andClaude Opus 5 committed 2026-09-22 23:17:01 +02:00
1 parent 5573c403f4
commit eeb94ad41a
7 files changed
+171 -57

No files matched your search

@@ -1,9 +1,11 @@
package kst4contest.view.map;
import kst4contest.ApplicationConstants;
import kst4contest.controller.DBController;
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.DriverManager;
import java.sql.PreparedStatement;
@@ -15,30 +17,44 @@ import java.util.Locale;
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:
* if the configured own callsign or own locator changes, all cached terrain
* profiles are cleared automatically.
* <p>Terrain profiles are pure geometry derived from two locators and a sample count.
* They do not belong to one operator, so the cache is deliberately not part of an
* 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 {
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;
public TerrainProfileCacheRepository() {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DBController.DATABASE_RESOURCE,
DBController.DATABASE_FILE
);
this.databasePath = ApplicationFileUtils.getFilePath(
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,
@@ -183,60 +199,37 @@ public final class TerrainProfileCacheRepository {
""");
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta (
meta_key TEXT NOT NULL PRIMARY KEY,
meta_value TEXT NOT NULL
CREATE TABLE IF NOT EXISTS TerrainProfileCacheOwner (
owner_callsign_raw 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,
String currentOwnerCallsignRaw,
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("""
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value)
VALUES (?, ?)
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value
INSERT INTO TerrainProfileCacheOwner (
owner_callsign_raw, owner_locator6, last_used_epoch_ms
) 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(2, value == null ? "" : value);
statement.setString(1, normalize(currentOwnerCallsignRaw));
statement.setString(2, normalize(currentOwnerLocator6));
statement.setLong(3, System.currentTimeMillis());
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);
}
}