From e1f617f2809b5cc3436ecbf00a67bd0c654688d8 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 16 Aug 2026 22:48:41 +0800 Subject: [PATCH] Optimize unified logging performance Signed-off-by: Loren Eteval --- Furious/Models/Logging.py | 1 + Furious/Service/LogManager.py | 58 ++++++++++++++++++++++++++++------- Furious/Window/LogPage.py | 48 ++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/Furious/Models/Logging.py b/Furious/Models/Logging.py index 77099e0..030eaf0 100644 --- a/Furious/Models/Logging.py +++ b/Furious/Models/Logging.py @@ -54,3 +54,4 @@ class LogEntry: categoryTranslatable: bool = False source: str = '' severity: str = '' + sequence: int = 0 diff --git a/Furious/Service/LogManager.py b/Furious/Service/LogManager.py index c23e72e..0585171 100644 --- a/Furious/Service/LogManager.py +++ b/Furious/Service/LogManager.py @@ -23,6 +23,7 @@ from Furious.Models.Logging import LogCategory, LogEntry from PySide6 import QtCore +from collections import deque from datetime import datetime from typing import Optional @@ -54,17 +55,28 @@ def formatLogEntry(entry: LogEntry) -> str: class LogManager(QtCore.QObject): """Own the application-wide categorized log stream.""" + DefaultMaximumEntries = 10_000 + categoryRegistered = QtCore.Signal(object) entryAdded = QtCore.Signal(object) entriesCleared = QtCore.Signal(object) - def __init__(self, parent=None): + def __init__(self, parent=None, *, maximumEntries=DefaultMaximumEntries): """Initialize the category registry and thread-safe entry collection.""" super().__init__(parent) + if ( + isinstance(maximumEntries, bool) + or not isinstance(maximumEntries, int) + or maximumEntries <= 0 + ): + raise ValueError('maximumEntries must be a positive integer') + self._lock = threading.RLock() self._categories: dict[str, LogCategory] = {} - self._entries: list[LogEntry] = [] + self._maximumEntries = maximumEntries + self._entries: deque[LogEntry] = deque(maxlen=maximumEntries) + self._sequence = 0 self.registerCategory( LogCategory( @@ -89,6 +101,11 @@ class LogManager(QtCore.QObject): ) ) + @property + def maximumEntries(self) -> int: + """Return the maximum number of structured entries retained in memory.""" + return self._maximumEntries + def registerCategory(self, category: LogCategory) -> LogCategory: """Register a filterable category and publish it exactly once.""" if not isinstance(category, LogCategory): @@ -161,6 +178,8 @@ class LogManager(QtCore.QObject): elif not isinstance(timestamp, datetime): raise TypeError('log timestamp must be a datetime') + self._sequence += 1 + entry = LogEntry( message=str(message).rstrip('\r\n'), timestamp=timestamp, @@ -169,7 +188,9 @@ class LogManager(QtCore.QObject): categoryTranslatable=category.translatable, source=str(source) if source else '', severity=str(severity) if severity else '', + sequence=self._sequence, ) + self._entries.append(entry) self.entryAdded.emit(entry) @@ -203,13 +224,22 @@ class LogManager(QtCore.QObject): def entries(self, categoryId: Optional[str] = None) -> tuple[LogEntry, ...]: """Return an immutable snapshot, optionally filtered by category.""" + return self.snapshot(categoryId)[1] + + def snapshot( + self, + categoryId: Optional[str] = None, + ) -> tuple[int, tuple[LogEntry, ...]]: + """Return the current sequence and its immutable filtered entries.""" with self._lock: if categoryId in (None, ALL_LOGS_FILTER): - return tuple(self._entries) + entries = tuple(self._entries) + else: + entries = tuple( + entry for entry in self._entries if entry.categoryId == categoryId + ) - return tuple( - entry for entry in self._entries if entry.categoryId == categoryId - ) + return self._sequence, entries def clear( self, @@ -238,14 +268,20 @@ class LogManager(QtCore.QObject): if clearedCategoryIds is None: changed = bool(self._entries) + self._entries.clear() else: oldLength = len(self._entries) - self._entries = [ - entry - for entry in self._entries - if entry.categoryId not in clearedCategoryIds - ] + + self._entries = deque( + ( + entry + for entry in self._entries + if entry.categoryId not in clearedCategoryIds + ), + maxlen=self._maximumEntries, + ) + changed = len(self._entries) != oldLength if changed: diff --git a/Furious/Window/LogPage.py b/Furious/Window/LogPage.py index 73959d7..a80a2f4 100644 --- a/Furious/Window/LogPage.py +++ b/Furious/Window/LogPage.py @@ -143,6 +143,10 @@ class LogPage(Mixins.QTranslatable, QMainWindow): pointSizeSettingsName='LogViewerWidgetPointSize', ) self.textBrowser.setLineWrapMode(DraculaTextBrowser.LineWrapMode.NoWrap) + self.textBrowser.document().setMaximumBlockCount(manager.maximumEntries) + + self._entriesDirty = True + self._renderedSequence = 0 filterLayout = QHBoxLayout() filterLayout.setContentsMargins(0, 0, 0, 0) @@ -247,7 +251,6 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self._registerMenuShortcuts(menu) self._populateFilters(self._preferredFilter) - self._refreshEntries() self.filterComboBox.currentIndexChanged.connect(self._filterChanged) self.manager.categoryRegistered.connect(self._categoryRegistered) @@ -301,15 +304,25 @@ class LogPage(Mixins.QTranslatable, QMainWindow): def _refreshEntries(self): """Render the current immutable filtered-entry snapshot.""" selectedCategoryId = self.filterComboBox.currentData() + sequence, entries = self.manager.snapshot(selectedCategoryId) + + content = '\n'.join(formatLogEntry(entry) for entry in entries) - content = '\n'.join( - formatLogEntry(entry) for entry in self.manager.entries(selectedCategoryId) - ) self.textBrowser.setPlainText(content) + self._renderedSequence = sequence scrollbar = self.textBrowser.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) + self._entriesDirty = False + + def _refreshEntriesIfVisible(self): + """Render pending entries only while this page is actually visible.""" + if self.isVisible(): + self._refreshEntries() + else: + self._entriesDirty = True + @QtCore.Slot(int) def _filterChanged(self, _index: int): """Persist and apply the category selected by the user.""" @@ -322,7 +335,7 @@ class LogPage(Mixins.QTranslatable, QMainWindow): AppSettings.set('LogViewerSelectedCategory', categoryId) - self._refreshEntries() + self._refreshEntriesIfVisible() @QtCore.Slot(object) def _categoryRegistered(self, category): @@ -338,6 +351,20 @@ class LogPage(Mixins.QTranslatable, QMainWindow): @QtCore.Slot(object) def _entryAdded(self, entry): """Append an entry when it matches the active filter.""" + if not self.isVisible(): + self._entriesDirty = True + + return + + if entry.sequence <= self._renderedSequence: + return + + if entry.sequence != self._renderedSequence + 1: + self._refreshEntries() + + return + + self._renderedSequence = entry.sequence categoryId = self.filterComboBox.currentData() if categoryId in (ALL_LOGS_FILTER, entry.categoryId): @@ -346,7 +373,14 @@ class LogPage(Mixins.QTranslatable, QMainWindow): @QtCore.Slot(object) def _entriesCleared(self, _categoryIds): """Refresh the presentation after the underlying collection changes.""" - self._refreshEntries() + self._refreshEntriesIfVisible() + + def showEvent(self, event): + """Render entries accumulated while the page was hidden.""" + super().showEvent(event) + + if self._entriesDirty: + self._refreshEntries() def plainText(self) -> str: """Return the plain text currently shown by the selected filter.""" @@ -364,4 +398,4 @@ class LogPage(Mixins.QTranslatable, QMainWindow): self.pageTitleLabel.setText(_('Log')) self.filterLabel.setText(_('Log Type')) self._populateFilters(selectedCategoryId) - self._refreshEntries() + self._refreshEntriesIfVisible()