Stop two servers taking turns claiming a contact's last sighting
A contact on two servers at once was marked seen by each in turn: a sighting announced a contact change, every connection re-applied its contacts, and each re-application recorded the contact on its own server — another change, round and round, rewriting contacts.txt every time. Re-applying now only marks a contact that was never seen; the last sighting moves when the client appears. Sightings no longer write the file on their own either: they reach it in one save half a minute later, or with the next edit's save, and are flushed when the client closes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,10 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The contact list (TeamSpeak's "Friend/Foe manager"): who is a friend, who is
|
||||
@@ -32,6 +36,9 @@ import java.util.concurrent.CopyOnWriteArrayList;
|
||||
*/
|
||||
public final class ContactStore {
|
||||
|
||||
/** How long a sighting may wait before it is written. */
|
||||
static final long SEEN_SAVE_DELAY_S = 30;
|
||||
|
||||
/** Told after the list changed in any way; called on whatever thread made the change. */
|
||||
public interface Listener {
|
||||
void onContactsChanged();
|
||||
@@ -46,6 +53,13 @@ public final class ContactStore {
|
||||
/** Immutable snapshot for readers, replaced under the lock after every change. */
|
||||
private volatile List<Contact> snapshot = List.of();
|
||||
private final List<Listener> listeners = new CopyOnWriteArrayList<>();
|
||||
/** Set while a sighting waits for {@link #flush()}; any save takes it along. */
|
||||
private final AtomicBoolean savePending = new AtomicBoolean();
|
||||
private final ScheduledExecutorService saver = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "contacts-save");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
ContactStore(File file) {
|
||||
this.file = file;
|
||||
@@ -89,6 +103,7 @@ public final class ContactStore {
|
||||
}
|
||||
|
||||
public void save() {
|
||||
savePending.set(false);
|
||||
List<Contact> all = snapshot;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Contact c : all) {
|
||||
@@ -211,7 +226,9 @@ public final class ContactStore {
|
||||
|
||||
/**
|
||||
* Notes that a contact is online right now. A sighting within the same minute on the
|
||||
* same server changes nothing, so a busy server does not rewrite the file per event.
|
||||
* same server changes nothing. Sightings reach the file in one go, {@link
|
||||
* #SEEN_SAVE_DELAY_S} later or with the next save, so a busy server does not rewrite it
|
||||
* per event.
|
||||
*/
|
||||
public void markSeen(String uniqueId, String serverName, String serverAddress) {
|
||||
Contact c = find(uniqueId);
|
||||
@@ -226,11 +243,21 @@ public final class ContactStore {
|
||||
c.lastSeenServerName = orEmpty(serverName);
|
||||
c.lastSeenServerAddress = orEmpty(serverAddress);
|
||||
}
|
||||
changed();
|
||||
if (savePending.compareAndSet(false, true)) saver.schedule(this::flush, SEEN_SAVE_DELAY_S, TimeUnit.SECONDS);
|
||||
announce();
|
||||
}
|
||||
|
||||
/** Writes sightings still waiting to be saved; for when the client goes away. */
|
||||
public void flush() {
|
||||
if (savePending.get()) save();
|
||||
}
|
||||
|
||||
private void changed() {
|
||||
save();
|
||||
announce();
|
||||
}
|
||||
|
||||
private void announce() {
|
||||
for (Listener l : listeners) l.onContactsChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -995,8 +995,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
/**
|
||||
* Applies a client's contact entry to its playback — the automatic mute and the
|
||||
* volume modifier — and records the sighting. Called when the client appears and
|
||||
* whenever its entry is edited.
|
||||
* volume modifier — and records the sighting. Called when the client appears.
|
||||
*/
|
||||
void applyContact(ClientEntry client) {
|
||||
Contact contact = applyContactPlayback(client);
|
||||
@@ -1016,12 +1015,18 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
/**
|
||||
* Re-applies every contact entry to the clients on the server, after the list changed.
|
||||
* A client that just became a contact is also marked as seen; the store only reports
|
||||
* that as a change once, so the re-application it triggers stops there.
|
||||
* A client that just became a contact is also marked as seen. One seen before keeps
|
||||
* where it was seen: with it on two servers, each re-application would claim it for its
|
||||
* own server, which is a change that triggers the other's re-application, and so on.
|
||||
*/
|
||||
public void applyContacts() {
|
||||
if (!connected) return;
|
||||
for (ClientEntry c : model.allClients()) applyContact(c);
|
||||
for (ClientEntry c : model.allClients()) {
|
||||
Contact contact = applyContactPlayback(c);
|
||||
if (contact != null && contact.lastSeen == null) {
|
||||
contacts.markSeen(c.uniqueId, model.getServerName(), serverHost + ":" + serverPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers a per-client volume, in the contact entry when the client has one. */
|
||||
|
||||
@@ -80,6 +80,28 @@ class ContactStoreTest {
|
||||
assertEquals("other:9987", a.lastSeenServerAddress);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sightingsWaitForTheNextSave() throws Exception {
|
||||
File file = new File(dir, "s.txt");
|
||||
ContactStore store = new ContactStore(file);
|
||||
store.add("a=", "A", ContactType.NEUTRAL, new ContactDefaults());
|
||||
long written = file.lastModified();
|
||||
String before = Files.readString(file.toPath(), StandardCharsets.UTF_8);
|
||||
|
||||
store.markSeen("a=", "Server", "host:9987");
|
||||
assertEquals(before, Files.readString(file.toPath(), StandardCharsets.UTF_8));
|
||||
store.flush();
|
||||
assertTrue(Files.readString(file.toPath(), StandardCharsets.UTF_8).contains("LastSeenServerAddress=host:9987"));
|
||||
|
||||
// An edit's save takes a waiting sighting along; the flush then has nothing left to write.
|
||||
store.markSeen("a=", "Other", "other:9987");
|
||||
store.put(store.find("a="));
|
||||
assertTrue(Files.readString(file.toPath(), StandardCharsets.UTF_8).contains("LastSeenServerAddress=other:9987"));
|
||||
assertTrue(file.setLastModified(written));
|
||||
store.flush();
|
||||
assertEquals(written, file.lastModified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeAddsNewAndOverwritesExisting() {
|
||||
ContactStore store = new ContactStore(new File(dir, "m.txt"));
|
||||
|
||||
Reference in New Issue
Block a user