Make LocalDB thread safe and simplify its API

This commit is contained in:
Kai S. K. Engelbart 2020-09-20 11:12:33 +02:00
parent be945fe3ee
commit d8ae8a65b8
2 changed files with 66 additions and 92 deletions

View File

@ -30,59 +30,55 @@ import dev.kske.eventbus.EventListener;
*/ */
public final class LocalDB implements EventListener { public final class LocalDB implements EventListener {
// Data
private User user; private User user;
private Map<String, User> users = new HashMap<>(); private Map<String, User> users = Collections.synchronizedMap(new HashMap<>());
private List<Chat> chats = new ArrayList<>(); private List<Chat> chats = Collections.synchronizedList(new ArrayList<>());
private IDGenerator idGenerator; private IDGenerator idGenerator;
private CacheMap cacheMap = new CacheMap(); private CacheMap cacheMap = new CacheMap();
private Instant lastSync = Instant.EPOCH;
private String authToken; private String authToken;
private File dbDir, userFile, idGeneratorFile, lastLoginFile, usersFile;
private FileLock instanceLock; // State management
private Instant lastSync = Instant.EPOCH;
// Persistence
private File dbDir, userFile, idGeneratorFile, lastLoginFile, usersFile;
private FileLock instanceLock;
/** /**
* Constructs an empty local database. To serialize any user-specific data to * Constructs an empty local database. To serialize any user-specific data to
* the file system, call {@link LocalDB#initializeUserStorage()} first * the file system, call {@link LocalDB#save(boolean)}.
* and then {@link LocalDB#save(boolean)}.
* *
* @param dbDir the directory in which to persist data * @param dbDir the directory in which to persist data
* @throws IOException if {@code dbDir} is a file (and not a directory) * @throws IOException if {@code dbDir} is a file (and not a directory)
* @throws EnvoyException if {@code dbDir} is in use by another Envoy instance
* @since Envoy Client v0.2-beta * @since Envoy Client v0.2-beta
*/ */
public LocalDB(File dbDir) throws IOException { public LocalDB(File dbDir) throws IOException, EnvoyException {
this.dbDir = dbDir; this.dbDir = dbDir;
EventBus.getInstance().registerListener(this); EventBus.getInstance().registerListener(this);
// Ensure that the database directory exists // Ensure that the database directory exists
if (!dbDir.exists()) { if (!dbDir.exists()) {
dbDir.mkdirs(); dbDir.mkdirs();
} else if (!dbDir.isDirectory()) } else if (!dbDir.isDirectory()) throw new IOException(String.format("LocalDBDir '%s' is not a directory!", dbDir.getAbsolutePath()));
throw new IOException(String.format("LocalDBDir '%s' is not a directory!", dbDir.getAbsolutePath()));
// Lock the directory
lock();
// Initialize global files // Initialize global files
idGeneratorFile = new File(dbDir, "id_gen.db"); idGeneratorFile = new File(dbDir, "id_gen.db");
lastLoginFile = new File(dbDir, "last_login.db"); lastLoginFile = new File(dbDir, "last_login.db");
usersFile = new File(dbDir, "users.db"); usersFile = new File(dbDir, "users.db");
// Load global files
loadGlobalData();
// Initialize offline caches // Initialize offline caches
cacheMap.put(Message.class, new Cache<>()); cacheMap.put(Message.class, new Cache<>());
cacheMap.put(MessageStatusChange.class, new Cache<>()); cacheMap.put(MessageStatusChange.class, new Cache<>());
} }
/**
* Creates a database file for a user-specific list of chats.
*
* @throws IllegalStateException if the client user is not specified
* @since Envoy Client v0.1-alpha
*/
public void initializeUserStorage() {
if (user == null) throw new IllegalStateException("Client user is null, cannot initialize user storage");
userFile = new File(dbDir, user.getID() + ".db");
}
FileChannel fc;
/** /**
* Ensured that only one Envoy instance is using this local database by creating * Ensured that only one Envoy instance is using this local database by creating
* a lock file. * a lock file.
@ -91,9 +87,8 @@ public final class LocalDB implements EventListener {
* @throws EnvoyException if the lock cannot by acquired * @throws EnvoyException if the lock cannot by acquired
* @since Envoy Client v0.2-beta * @since Envoy Client v0.2-beta
*/ */
public void lock() throws EnvoyException { private synchronized void lock() throws EnvoyException {
File file = new File(dbDir, "instance.lock"); File file = new File(dbDir, "instance.lock");
file.deleteOnExit();
try { try {
FileChannel fc = FileChannel.open(file.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE); FileChannel fc = FileChannel.open(file.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
instanceLock = fc.tryLock(); instanceLock = fc.tryLock();
@ -104,37 +99,24 @@ public final class LocalDB implements EventListener {
} }
/** /**
* Stores all users. If the client user is specified, their chats will be stored * Loads the local user registry {@code users.db}, the id generator
* as well. The message id generator will also be saved if present. * {@code id_gen.db} and last login file {@code last_login.db}.
* *
* @param isOnline determines which {@code lastSync} time stamp is saved * @since Envoy Client v0.2-beta
* @throws IOException if the saving process failed
* @since Envoy Client v0.3-alpha
*/ */
public void save(boolean isOnline) throws IOException { private synchronized void loadGlobalData() {
try {
// Save users try (var in = new ObjectInputStream(new FileInputStream(usersFile))) {
SerializationUtils.write(usersFile, users); users = (Map<String, User>) in.readObject();
}
// Save user data and last sync time stamp idGenerator = SerializationUtils.read(idGeneratorFile, IDGenerator.class);
if (user != null) SerializationUtils.write(userFile, chats, cacheMap, isOnline ? Instant.now() : lastSync); try (var in = new ObjectInputStream(new FileInputStream(lastLoginFile))) {
user = (User) in.readObject();
// Save last login information authToken = (String) in.readObject();
if (authToken != null) SerializationUtils.write(lastLoginFile, user, authToken); }
} catch (IOException | ClassNotFoundException e) {}
// Save id generator
if (hasIDGenerator()) SerializationUtils.write(idGeneratorFile, idGenerator);
} }
/**
* Loads all user data.
*
* @throws ClassNotFoundException if the loading process failed
* @throws IOException if the loading process failed
* @since Envoy Client v0.3-alpha
*/
public void loadUsers() throws ClassNotFoundException, IOException { users = SerializationUtils.read(usersFile, HashMap.class); }
/** /**
* Loads all data of the client user. * Loads all data of the client user.
* *
@ -142,36 +124,15 @@ public final class LocalDB implements EventListener {
* @throws IOException if the loading process failed * @throws IOException if the loading process failed
* @since Envoy Client v0.3-alpha * @since Envoy Client v0.3-alpha
*/ */
public void loadUserData() throws ClassNotFoundException, IOException { public synchronized void loadUserData() throws ClassNotFoundException, IOException {
if (user == null) throw new IllegalStateException("Client user is null, cannot initialize user storage");
userFile = new File(dbDir, user.getID() + ".db");
try (var in = new ObjectInputStream(new FileInputStream(userFile))) { try (var in = new ObjectInputStream(new FileInputStream(userFile))) {
chats = (ArrayList<Chat>) in.readObject(); chats = (List<Chat>) in.readObject();
cacheMap = (CacheMap) in.readObject(); cacheMap = (CacheMap) in.readObject();
lastSync = (Instant) in.readObject(); lastSync = (Instant) in.readObject();
} }
} synchronize();
/**
* Loads the ID generator. Any exception thrown during this process is ignored.
*
* @since Envoy Client v0.3-alpha
*/
public void loadIDGenerator() {
try {
idGenerator = SerializationUtils.read(idGeneratorFile, IDGenerator.class);
} catch (ClassNotFoundException | IOException e) {}
}
/**
* Loads the last login information. Any exception thrown during this process is
* ignored.
*
* @since Envoy Client v0.2-beta
*/
public void loadLastLogin() {
try (var in = new ObjectInputStream(new FileInputStream(lastLoginFile))) {
user = (User) in.readObject();
authToken = (String) in.readObject();
} catch (ClassNotFoundException | IOException e) {}
} }
/** /**
@ -180,7 +141,7 @@ public final class LocalDB implements EventListener {
* *
* @since Envoy Client v0.1-beta * @since Envoy Client v0.1-beta
*/ */
public void synchronize() { private void synchronize() {
user.getContacts().stream().filter(u -> u instanceof User && !users.containsKey(u.getName())).forEach(u -> users.put(u.getName(), (User) u)); user.getContacts().stream().filter(u -> u instanceof User && !users.containsKey(u.getName())).forEach(u -> users.put(u.getName(), (User) u));
users.put(user.getName(), user); users.put(user.getName(), user);
@ -197,11 +158,33 @@ public final class LocalDB implements EventListener {
.forEach(chats::add); .forEach(chats::add);
} }
@Event /**
private void onNewAuthToken(NewAuthToken evt) { * Stores all users. If the client user is specified, their chats will be stored
authToken = evt.get(); * as well. The message id generator will also be saved if present.
*
* @param isOnline determines which {@code lastSync} time stamp is saved
* @throws IOException if the saving process failed
* @since Envoy Client v0.3-alpha
*/
public synchronized void save(boolean isOnline) throws IOException {
// Save users
SerializationUtils.write(usersFile, users);
// Save user data and last sync time stamp
if (user != null) SerializationUtils.write(userFile, chats, cacheMap, isOnline ? Instant.now() : lastSync);
// Save last login information
if (authToken != null) SerializationUtils.write(lastLoginFile, user, authToken);
// Save id generator
if (hasIDGenerator()) SerializationUtils.write(idGeneratorFile, idGenerator);
} }
@Event
private void onNewAuthToken(NewAuthToken evt) { authToken = evt.get(); }
/** /**
* @return a {@code Map<String, User>} of all users stored locally with their * @return a {@code Map<String, User>} of all users stored locally with their
* user names as keys * user names as keys

View File

@ -70,7 +70,6 @@ public final class Startup extends Application {
File localDBDir = new File(config.getHomeDirectory(), config.getLocalDB().getPath()); File localDBDir = new File(config.getHomeDirectory(), config.getLocalDB().getPath());
logger.info("Initializing LocalDB at " + localDBDir); logger.info("Initializing LocalDB at " + localDBDir);
localDB = new LocalDB(localDBDir); localDB = new LocalDB(localDBDir);
localDB.lock();
} catch (IOException | EnvoyException e) { } catch (IOException | EnvoyException e) {
logger.log(Level.SEVERE, "Could not initialize local database: ", e); logger.log(Level.SEVERE, "Could not initialize local database: ", e);
new Alert(AlertType.ERROR, "Could not initialize local database!\n" + e).showAndWait(); new Alert(AlertType.ERROR, "Could not initialize local database!\n" + e).showAndWait();
@ -79,8 +78,6 @@ public final class Startup extends Application {
} }
// Prepare handshake // Prepare handshake
localDB.loadIDGenerator();
localDB.loadLastLogin();
context.setLocalDB(localDB); context.setLocalDB(localDB);
// Configure stage // Configure stage
@ -94,7 +91,6 @@ public final class Startup extends Application {
// Authenticate with token if present // Authenticate with token if present
if (localDB.getAuthToken() != null) { if (localDB.getAuthToken() != null) {
logger.info("Attempting authentication with token..."); logger.info("Attempting authentication with token...");
localDB.initializeUserStorage();
localDB.loadUserData(); localDB.loadUserData();
if (!performHandshake( if (!performHandshake(
LoginCredentials.loginWithToken(localDB.getUser().getName(), localDB.getAuthToken(), VERSION, localDB.getLastSync()))) LoginCredentials.loginWithToken(localDB.getUser().getName(), localDB.getAuthToken(), VERSION, localDB.getLastSync())))
@ -146,7 +142,6 @@ public final class Startup extends Application {
public static boolean attemptOfflineMode(String identifier) { public static boolean attemptOfflineMode(String identifier) {
try { try {
// Try entering offline mode // Try entering offline mode
localDB.loadUsers();
final User clientUser = localDB.getUsers().get(identifier); final User clientUser = localDB.getUsers().get(identifier);
if (clientUser == null) throw new EnvoyException("Could not enter offline mode: user name unknown"); if (clientUser == null) throw new EnvoyException("Could not enter offline mode: user name unknown");
client.setSender(clientUser); client.setSender(clientUser);
@ -169,9 +164,7 @@ public final class Startup extends Application {
*/ */
public static Instant loadLastSync(String identifier) { public static Instant loadLastSync(String identifier) {
try { try {
localDB.loadUsers();
localDB.setUser(localDB.getUsers().get(identifier)); localDB.setUser(localDB.getUsers().get(identifier));
localDB.initializeUserStorage();
localDB.loadUserData(); localDB.loadUserData();
} catch (final Exception e) { } catch (final Exception e) {
// User storage empty, wrong user name etc. -> default lastSync // User storage empty, wrong user name etc. -> default lastSync
@ -186,7 +179,6 @@ public final class Startup extends Application {
// Initialize chats in local database // Initialize chats in local database
try { try {
localDB.initializeUserStorage();
localDB.loadUserData(); localDB.loadUserData();
} catch (final FileNotFoundException e) { } catch (final FileNotFoundException e) {
// The local database file has not yet been created, probably first login // The local database file has not yet been created, probably first login
@ -196,7 +188,6 @@ public final class Startup extends Application {
} }
context.initWriteProxy(); context.initWriteProxy();
localDB.synchronize();
if (client.isOnline()) context.getWriteProxy().flushCache(); if (client.isOnline()) context.getWriteProxy().flushCache();
else else