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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 10:51:01 +00:00
parent f9b2f4d7cb
commit a5c4ba1eb5
2 changed files with 53 additions and 9 deletions

View File

@@ -8,6 +8,8 @@ import com.ts3client.text.TsLink;
import java.io.BufferedWriter; import java.io.BufferedWriter;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -28,6 +30,11 @@ import java.util.regex.Pattern;
*/ */
public final class ChatLogStore { public final class ChatLogStore {
public static final int HISTORY_LIMIT = 1000; 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 BEGIN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss"); private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss");
@@ -57,15 +64,17 @@ public final class ChatLogStore {
} }
public List<String> serverHtmlHistory() throws IOException { public List<String> serverHtmlHistory() throws IOException {
return last(readHtml(log(ChatLogScope.SERVER)), HISTORY_LIMIT); return last(readHtml(log(ChatLogScope.SERVER), HISTORY_TAIL_BYTES), HISTORY_LIMIT);
} }
public List<String> channelHtmlHistory() throws IOException { public List<String> 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<String> privateHtmlHistory(String clientUniqueId) throws IOException { public List<String> 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. */ /** 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) public SearchResult search(ChatLogScope scope, String clientUniqueId, TextQuery query, int limit)
throws IOException { throws IOException {
List<String> all = scope == ChatLogScope.SERVER List<String> all = scope == ChatLogScope.SERVER
? readHtml(log(scope)) ? readHtml(log(scope), Long.MAX_VALUE)
: readTextMessages(scope == ChatLogScope.PRIVATE ? privateLog(clientUniqueId) : log(scope), scope); : readTextMessages(scope == ChatLogScope.PRIVATE ? privateLog(clientUniqueId) : log(scope), scope,
Long.MAX_VALUE);
List<String> hits = new ArrayList<>(); List<String> hits = new ArrayList<>();
for (String entry : all) { for (String entry : all) {
if (query.matches(HtmlText.plain(entry))) hits.add(entry); 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); if (!Files.exists(files.txt)) Files.createFile(files.txt);
} }
private static List<String> readHtml(LogFiles files) throws IOException { /** @param tailBytes how much of the end of the log to read */
private static List<String> readHtml(LogFiles files, long tailBytes) throws IOException {
if (!Files.isRegularFile(files.html)) return Collections.emptyList(); if (!Files.isRegularFile(files.html)) return Collections.emptyList();
List<String> entries = new ArrayList<>(); List<String> entries = new ArrayList<>();
for (String line : Files.readAllLines(files.html, StandardCharsets.UTF_8)) { for (String line : readLines(files.html, tailBytes)) {
if (line.startsWith("<p ")) entries.add(line); if (line.startsWith("<p ")) entries.add(line);
} }
return entries; return entries;
@@ -179,12 +190,13 @@ public final class ChatLogStore {
* A message is written as it was sent, so one with line breaks spans several lines of the * A message is written as it was sent, so one with line breaks spans several lines of the
* log: a line that starts no entry of its own continues the message before it. * log: a line that starts no entry of its own continues the message before it.
*/ */
private static List<String> readTextMessages(LogFiles files, ChatLogScope scope) throws IOException { private static List<String> readTextMessages(LogFiles files, ChatLogScope scope, long tailBytes)
throws IOException {
if (!Files.isRegularFile(files.txt)) return Collections.emptyList(); if (!Files.isRegularFile(files.txt)) return Collections.emptyList();
List<String> entries = new ArrayList<>(); List<String> entries = new ArrayList<>();
Matcher message = null; Matcher message = null;
StringBuilder text = new StringBuilder(); 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); Matcher m = TEXT_MESSAGE.matcher(line);
boolean begin = line.startsWith("*** "); boolean begin = line.startsWith("*** ");
if (!m.matches() && !begin) { if (!m.matches() && !begin) {
@@ -205,6 +217,22 @@ public final class ChatLogStore {
return entries; return entries;
} }
/** The lines in the last {@code tailBytes} of a file, less the one that was cut into. */
private static List<String> 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<String> lines = new ArrayList<>(List.of(text.split("\r?\n", -1)));
lines.remove(0);
return lines;
}
}
private static List<String> last(List<String> entries, int limit) { private static List<String> last(List<String> entries, int limit) {
if (entries.size() <= limit) return entries; if (entries.size() <= limit) return entries;
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size())); return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));

View File

@@ -80,4 +80,20 @@ class ChatLogStoreTest {
assertTrue(history.get(0).contains("first line<br><br>third line"), history.get(0)); assertTrue(history.get(0).contains("first line<br><br>third line"), history.get(0));
assertTrue(history.get(1).contains("reply")); 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<String> 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));
}
} }