Add UserRegistry to store all existing users and UserCleanupJob to periodicly remove expired user #189

Merged
lars.winzer merged 12 commits from feat/user-serverside into main 2026-03-15 20:00:59 +01:00
4 changed files with 92 additions and 0 deletions
Showing only changes of commit 0a5eb5d11c - Show all commits
@@ -4,12 +4,20 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
import java.time.Instant; import java.time.Instant;
import java.util.Optional; import java.util.Optional;
/** Represents an authenticated user on the server. */
public class User { public class User {
private final UserId id; private final UserId id;
private final String name; private final String name;
private SessionId sessionId; private SessionId sessionId;
private Instant disconnectedAt; private Instant disconnectedAt;
/**
* Creates a new User with the given ID, name and session.
*
* @param id the unique identifier for this user
* @param name the display name of this user
* @param sessionId the session currently associated with this user
*/
public User(UserId id, String name, SessionId sessionId) { public User(UserId id, String name, SessionId sessionId) {
this.id = id; this.id = id;
this.name = name; this.name = name;
@@ -17,27 +25,53 @@ public class User {
this.disconnectedAt = null; this.disconnectedAt = null;
} }
/**
* Returns the ID of this user.
*
* @return the user ID
*/
public UserId getId() { public UserId getId() {
return id; return id;
} }
/**
* Returns the display name of this user.
*
* @return the user name
*/
public String getName() { public String getName() {
return name; return name;
} }
/**
* Returns the session currently associated with this user, if any.
*
* @return an Optional containing the session ID, or empty if disconnected
*/
public Optional<SessionId> getSessionId() { public Optional<SessionId> getSessionId() {
return Optional.ofNullable(sessionId); return Optional.ofNullable(sessionId);
} }
/**
* Returns the time at which this user disconnected, if applicable.
*
* @return an Optional containing the disconnect timestamp, or empty if connected
*/
public Optional<Instant> getDisconnectedAt() { public Optional<Instant> getDisconnectedAt() {
return Optional.ofNullable(disconnectedAt); return Optional.ofNullable(disconnectedAt);
} }
/**
* Associates this user with a new session, clearing the disconnect timestamp.
*
* @param sessionId the new session ID
*/
public void reassignSession(SessionId sessionId) { public void reassignSession(SessionId sessionId) {
this.sessionId = sessionId; this.sessionId = sessionId;
this.disconnectedAt = null; this.disconnectedAt = null;
} }
/** Marks this user as disconnected by clearing the session and recording the timestamp. */
public void markDisconnected() { public void markDisconnected() {
this.sessionId = null; this.sessionId = null;
this.disconnectedAt = Instant.now(); this.disconnectedAt = Instant.now();
@@ -5,6 +5,10 @@ import java.time.Instant;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
/**
* Periodically periodicly run job to remove disconnected users who have exceeded the reconnect
* threshold.
*/
public class UserCleanupJob implements Runnable { public class UserCleanupJob implements Runnable {
private static final Logger logger = LogManager.getLogger(UserCleanupJob.class); private static final Logger logger = LogManager.getLogger(UserCleanupJob.class);
@@ -2,13 +2,28 @@ package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
/** Creates new users, resolving name conflicts automatically. */
public class UserFactory { public class UserFactory {
private final UserRegistry registry; private final UserRegistry registry;
/**
* Creates a new UserFactory backed by the given registry.
*
* @param registry the registry to register new users to
*/
public UserFactory(UserRegistry registry) { public UserFactory(UserRegistry registry) {
this.registry = registry; this.registry = registry;
} }
/**
* Creates and registers a new user with the given name and session. If the name is already
* taken, a numeric suffix is appended and incremented until a free name is found (e.g.
* Lars_001, Lars_002, ...).
*
* @param desiredName the preferred display name
* @param sessionId the session to associate with the new user
* @return the newly created and registered user
*/
public User create(String desiredName, SessionId sessionId) { public User create(String desiredName, SessionId sessionId) {
var result = registry.registerIfAvailable(desiredName, sessionId); var result = registry.registerIfAvailable(desiredName, sessionId);
if (result.isPresent()) { if (result.isPresent()) {
@@ -5,11 +5,20 @@ import java.util.Collection;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** Manages all active users on the server. */
public class UserRegistry { public class UserRegistry {
private final ConcurrentHashMap<UserId, User> byId = new ConcurrentHashMap<>(); private final ConcurrentHashMap<UserId, User> byId = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, User> byName = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, User> byName = new ConcurrentHashMap<>();
private final ConcurrentHashMap<SessionId, User> bySessionId = new ConcurrentHashMap<>(); private final ConcurrentHashMap<SessionId, User> bySessionId = new ConcurrentHashMap<>();
/**
* Attempts to register a user under the given name atomically. Returns the registered user, or
* empty if the name is already taken.
*
* @param name the desired display name
* @param sessionId the session to associate with the new user
* @return an Optional containing the new user, or empty if the name was taken
*/
public synchronized Optional<User> registerIfAvailable(String name, SessionId sessionId) { public synchronized Optional<User> registerIfAvailable(String name, SessionId sessionId) {
if (byName.containsKey(name)) { if (byName.containsKey(name)) {
return Optional.empty(); return Optional.empty();
@@ -22,6 +31,12 @@ public class UserRegistry {
return Optional.of(user); return Optional.of(user);
} }
/**
* Removes the user with the given ID, but only if they are still disconnected. This prevents
* removing a user who has reconnected between the cleanup job's check and its removal call.
*
* @param userId the ID of the user to remove
*/
public synchronized boolean removeIfStillDisconnected(UserId userId) { public synchronized boolean removeIfStillDisconnected(UserId userId) {
User user = byId.get(userId); User user = byId.get(userId);
if (user == null) { if (user == null) {
@@ -37,6 +52,12 @@ public class UserRegistry {
return true; return true;
} }
/**
* Marks the user associated with the given session as disconnected, clearing the session
* association and recording the disconnect timestamp.
*
* @param sessionId the session ID of the disconnected client
*/
// TODO: Add to EventRegistry with DisconnectEvent // TODO: Add to EventRegistry with DisconnectEvent
public synchronized void onDisconnect(SessionId sessionId) { public synchronized void onDisconnect(SessionId sessionId) {
User user = bySessionId.remove(sessionId); User user = bySessionId.remove(sessionId);
@@ -47,6 +68,13 @@ public class UserRegistry {
user.markDisconnected(); user.markDisconnected();
} }
/**
* Reassociates a user with a new session, effectively restoring them after a reconnect.
*
* @param userId the ID of the user to reconnect
* @param sessionId the new session ID
* @return an Optional containing the user, or empty if the user was not found
*/
public synchronized Optional<User> reassignSession(UserId userId, SessionId sessionId) { public synchronized Optional<User> reassignSession(UserId userId, SessionId sessionId) {
User user = byId.get(userId); User user = byId.get(userId);
if (user == null) { if (user == null) {
@@ -58,10 +86,21 @@ public class UserRegistry {
return Optional.of(user); return Optional.of(user);
} }
/**
* Looks up a user by their session ID.
*
* @param sessionId the session ID to look up
* @return an Optional containing the user, or empty if no user is associated with this session
*/
public Optional<User> findBySessionId(SessionId sessionId) { public Optional<User> findBySessionId(SessionId sessionId) {
return Optional.ofNullable(bySessionId.get(sessionId)); return Optional.ofNullable(bySessionId.get(sessionId));
} }
/**
* Returns all currently registered users.
*
* @return a collection of all users
*/
public Collection<User> getAllUsers() { public Collection<User> getAllUsers() {
return byId.values(); return byId.values();
} }