Optimize unified logging performance

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-17 08:52:20 +08:00
parent 477886296d
commit e1f617f280
3 changed files with 89 additions and 18 deletions
+1
View File
@@ -54,3 +54,4 @@ class LogEntry:
categoryTranslatable: bool = False
source: str = ''
severity: str = ''
sequence: int = 0
+47 -11
View File
@@ -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:
+41 -7
View File
@@ -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()