From a5c4ba1eb57c4509de3ba2f29b9c82091093ebeb Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 25 Sep 2026 10:51:01 +0000 Subject: [PATCH] Load chat history from the end of the log only Chat logs grow forever, and every connect and every private chat opened read the whole log to keep its last 1000 entries. History now reads the last 4 MiB, which holds far more than that; searching still reads the whole log. Co-Authored-By: Claude Opus 5.5 --- .../com/ts3client/chatlog/ChatLogStore.java | 46 +++++++++++++++---- .../ts3client/chatlog/ChatLogStoreTest.java | 16 +++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java index ec8ddb8..13e0f63 100644 --- a/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java @@ -8,6 +8,8 @@ import com.ts3client.text.TsLink; import java.io.BufferedWriter; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -28,6 +30,11 @@ import java.util.regex.Pattern; */ public final class ChatLogStore { public static final int HISTORY_LIMIT = 1000; + /** + * How much of the end of a log history is read from: far more than {@link #HISTORY_LIMIT} + * entries take, without reading years of log on every connect. + */ + private static final int HISTORY_TAIL_BYTES = 4 << 20; private static final DateTimeFormatter BEGIN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss"); @@ -57,15 +64,17 @@ public final class ChatLogStore { } public List serverHtmlHistory() throws IOException { - return last(readHtml(log(ChatLogScope.SERVER)), HISTORY_LIMIT); + return last(readHtml(log(ChatLogScope.SERVER), HISTORY_TAIL_BYTES), HISTORY_LIMIT); } public List channelHtmlHistory() throws IOException { - return last(readTextMessages(log(ChatLogScope.CHANNEL), ChatLogScope.CHANNEL), HISTORY_LIMIT); + return last(readTextMessages(log(ChatLogScope.CHANNEL), ChatLogScope.CHANNEL, HISTORY_TAIL_BYTES), + HISTORY_LIMIT); } public List privateHtmlHistory(String clientUniqueId) throws IOException { - return last(readTextMessages(privateLog(clientUniqueId), ChatLogScope.PRIVATE), HISTORY_LIMIT); + return last(readTextMessages(privateLog(clientUniqueId), ChatLogScope.PRIVATE, HISTORY_TAIL_BYTES), + HISTORY_LIMIT); } /** Entries of a whole log that match a query, in the order they were written. */ @@ -84,8 +93,9 @@ public final class ChatLogStore { public SearchResult search(ChatLogScope scope, String clientUniqueId, TextQuery query, int limit) throws IOException { List all = scope == ChatLogScope.SERVER - ? readHtml(log(scope)) - : readTextMessages(scope == ChatLogScope.PRIVATE ? privateLog(clientUniqueId) : log(scope), scope); + ? readHtml(log(scope), Long.MAX_VALUE) + : readTextMessages(scope == ChatLogScope.PRIVATE ? privateLog(clientUniqueId) : log(scope), scope, + Long.MAX_VALUE); List hits = new ArrayList<>(); for (String entry : all) { if (query.matches(HtmlText.plain(entry))) hits.add(entry); @@ -166,10 +176,11 @@ public final class ChatLogStore { if (!Files.exists(files.txt)) Files.createFile(files.txt); } - private static List readHtml(LogFiles files) throws IOException { + /** @param tailBytes how much of the end of the log to read */ + private static List readHtml(LogFiles files, long tailBytes) throws IOException { if (!Files.isRegularFile(files.html)) return Collections.emptyList(); List entries = new ArrayList<>(); - for (String line : Files.readAllLines(files.html, StandardCharsets.UTF_8)) { + for (String line : readLines(files.html, tailBytes)) { if (line.startsWith("

readTextMessages(LogFiles files, ChatLogScope scope) throws IOException { + private static List readTextMessages(LogFiles files, ChatLogScope scope, long tailBytes) + throws IOException { if (!Files.isRegularFile(files.txt)) return Collections.emptyList(); List entries = new ArrayList<>(); Matcher message = null; StringBuilder text = new StringBuilder(); - for (String line : Files.readAllLines(files.txt, StandardCharsets.UTF_8)) { + for (String line : readLines(files.txt, tailBytes)) { Matcher m = TEXT_MESSAGE.matcher(line); boolean begin = line.startsWith("*** "); if (!m.matches() && !begin) { @@ -205,6 +217,22 @@ public final class ChatLogStore { return entries; } + /** The lines in the last {@code tailBytes} of a file, less the one that was cut into. */ + private static List readLines(Path file, long tailBytes) throws IOException { + try (SeekableByteChannel channel = Files.newByteChannel(file)) { + long size = channel.size(); + if (size <= tailBytes) return Files.readAllLines(file, StandardCharsets.UTF_8); + ByteBuffer tail = ByteBuffer.allocate((int) tailBytes); + channel.position(size - tailBytes); + int read = 0; + while (tail.hasRemaining() && read >= 0) read = channel.read(tail); + String text = new String(tail.array(), 0, tail.position(), StandardCharsets.UTF_8); + List lines = new ArrayList<>(List.of(text.split("\r?\n", -1))); + lines.remove(0); + return lines; + } + } + private static List last(List entries, int limit) { if (entries.size() <= limit) return entries; return new ArrayList<>(entries.subList(entries.size() - limit, entries.size())); diff --git a/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java b/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java index 260b22b..2942b34 100644 --- a/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java +++ b/ts3-client/core/src/test/java/com/ts3client/chatlog/ChatLogStoreTest.java @@ -80,4 +80,20 @@ class ChatLogStoreTest { assertTrue(history.get(0).contains("first line

third line"), history.get(0)); assertTrue(history.get(1).contains("reply")); } + + @Test + void historyComesFromTheEndOfALongLog() throws Exception { + ChatLogStore logs = new ChatLogStore(temp, "+Tyg2JtxE8vRNZp+JiUBnmBh0MY="); + logs.appendMessage(ChatLogScope.CHANNEL, false, 5, "uid", "alice", "start"); + Path txt = logs.serverDir().resolve("channel.txt"); + StringBuilder filler = new StringBuilder(); + for (int i = 0; i < 200_000; i++) filler.append("<12:00:00> alice: žluťoučký kůň ").append(i).append('\n'); + Files.writeString(txt, filler, java.nio.file.StandardOpenOption.APPEND); + logs.appendMessage(ChatLogScope.CHANNEL, false, 5, "uid", "bob", "the latest"); + + List history = logs.channelHtmlHistory(); + assertEquals(ChatLogStore.HISTORY_LIMIT, history.size()); + assertTrue(history.get(history.size() - 1).contains("the latest")); + assertTrue(history.get(0).contains("žluťoučký kůň"), history.get(0)); + } }