mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: bound async log writer shutdown
This commit is contained in:
+168
-1
@@ -2,7 +2,14 @@ import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.runtime.log import LogEntry, NonBlockingFileHandler, log_settings
|
||||
import pytest
|
||||
|
||||
from app.runtime.log import (
|
||||
LogEntry,
|
||||
LoggerManager,
|
||||
NonBlockingFileHandler,
|
||||
log_settings,
|
||||
)
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
|
||||
@@ -147,3 +154,163 @@ def test_non_blocking_file_handler_uses_handler_lock(monkeypatch, tmp_path):
|
||||
finally:
|
||||
handler.shutdown()
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_shutdown_is_bounded_and_retryable(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""批量写入阻塞时关闭必须有限返回,并保留同一 writer 供重试。"""
|
||||
original_instance = NonBlockingFileHandler._instance
|
||||
NonBlockingFileHandler._instance = None
|
||||
monkeypatch.setattr(log_settings, "WRITE_TIMEOUT", 0.01)
|
||||
handler = NonBlockingFileHandler()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def block_batch(_batch):
|
||||
"""模拟文件系统写入永久占用批量 writer。"""
|
||||
entered.set()
|
||||
release.wait()
|
||||
|
||||
monkeypatch.setattr(handler, "_write_batch", block_batch)
|
||||
try:
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "blocked", tmp_path / "blocked.log")
|
||||
) is True
|
||||
assert entered.wait(timeout=1)
|
||||
|
||||
started_at = time.monotonic()
|
||||
assert handler.shutdown(timeout=0.01) is False
|
||||
assert time.monotonic() - started_at < 1
|
||||
assert handler._write_thread.is_alive()
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "late", tmp_path / "blocked.log")
|
||||
) is False
|
||||
release.set()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
assert not handler._write_thread.is_alive()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
finally:
|
||||
release.set()
|
||||
handler.shutdown(timeout=1)
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_does_not_bypass_full_queue(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""队列达到显式容量后不得再通过无界线程池形成第二条写入路径。"""
|
||||
original_instance = NonBlockingFileHandler._instance
|
||||
NonBlockingFileHandler._instance = None
|
||||
monkeypatch.setattr(log_settings, "ASYNC_FILE_QUEUE_SIZE", 1)
|
||||
monkeypatch.setattr(log_settings, "BATCH_WRITE_SIZE", 1)
|
||||
handler = NonBlockingFileHandler()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
written: list[str] = []
|
||||
|
||||
def block_batch(batch):
|
||||
"""占住唯一 writer,使后续日志稳定留在有界队列中。"""
|
||||
written.extend(entry.message for entry in batch)
|
||||
entered.set()
|
||||
release.wait()
|
||||
|
||||
monkeypatch.setattr(handler, "_write_batch", block_batch)
|
||||
try:
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "first", tmp_path / "bounded.log")
|
||||
) is True
|
||||
assert entered.wait(timeout=1)
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "queued", tmp_path / "bounded.log")
|
||||
) is True
|
||||
assert handler._write_non_blocking(
|
||||
LogEntry("info", "rejected", tmp_path / "bounded.log")
|
||||
) is False
|
||||
release.set()
|
||||
started_at = time.monotonic()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
assert time.monotonic() - started_at < 1
|
||||
assert written == ["first", "queued"]
|
||||
finally:
|
||||
release.set()
|
||||
handler.shutdown(timeout=1)
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_non_blocking_file_handler_bounds_handler_close(monkeypatch, tmp_path):
|
||||
"""文件处理器 close 阻塞时也必须保留关闭线程并支持最终重试。"""
|
||||
original_instance = NonBlockingFileHandler._instance
|
||||
NonBlockingFileHandler._instance = None
|
||||
handler = NonBlockingFileHandler()
|
||||
close_entered = threading.Event()
|
||||
close_release = threading.Event()
|
||||
log_handler = MagicMock()
|
||||
|
||||
def block_close():
|
||||
"""模拟文件系统在 flush 后阻塞关闭句柄。"""
|
||||
close_entered.set()
|
||||
close_release.wait()
|
||||
|
||||
log_handler.close.side_effect = block_close
|
||||
log_path = tmp_path / "close.log"
|
||||
handler._rotating_handlers = {log_path: log_handler}
|
||||
try:
|
||||
assert handler.shutdown(timeout=0.05) is False
|
||||
assert close_entered.wait(timeout=1)
|
||||
assert handler._close_thread is not None
|
||||
assert handler._close_thread.is_alive()
|
||||
assert handler._rotating_handlers[log_path] is log_handler
|
||||
close_release.set()
|
||||
assert handler.shutdown(timeout=1) is True
|
||||
assert handler._rotating_handlers == {}
|
||||
finally:
|
||||
close_release.set()
|
||||
handler.shutdown(timeout=1)
|
||||
NonBlockingFileHandler._instance = original_instance
|
||||
|
||||
|
||||
def test_logger_manager_retains_nonconverged_writer_for_retry(tmp_path):
|
||||
"""平台日志门面不得在底层 writer 未收敛时丢失其 owner。"""
|
||||
previous_writer = LoggerManager._writer
|
||||
previous_log_path = LoggerManager._log_path
|
||||
writer = MagicMock()
|
||||
writer.shutdown.side_effect = [False, True]
|
||||
LoggerManager._writer = writer
|
||||
LoggerManager._log_path = tmp_path
|
||||
try:
|
||||
assert LoggerManager.shutdown() is False
|
||||
assert LoggerManager._writer is writer
|
||||
assert LoggerManager._log_path == tmp_path
|
||||
|
||||
assert LoggerManager.shutdown() is True
|
||||
assert LoggerManager._writer is None
|
||||
assert LoggerManager._log_path is None
|
||||
assert writer.shutdown.call_count == 2
|
||||
finally:
|
||||
LoggerManager._writer = previous_writer
|
||||
LoggerManager._log_path = previous_log_path
|
||||
|
||||
|
||||
def test_logger_manager_refuses_to_replace_nonconverged_writer(tmp_path):
|
||||
"""重新装配不得用新 writer 覆盖仍持有资源的旧 owner。"""
|
||||
original_writer = LoggerManager._writer
|
||||
original_log_path = LoggerManager._log_path
|
||||
previous_writer = MagicMock()
|
||||
previous_writer.shutdown.return_value = False
|
||||
replacement_writer = MagicMock()
|
||||
old_path = tmp_path / "old"
|
||||
LoggerManager._writer = previous_writer
|
||||
LoggerManager._log_path = old_path
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="既有日志写入器未收敛"):
|
||||
LoggerManager.configure_writer(replacement_writer, tmp_path / "new")
|
||||
|
||||
assert LoggerManager._writer is previous_writer
|
||||
assert LoggerManager._log_path == old_path
|
||||
replacement_writer.write_log.assert_not_called()
|
||||
finally:
|
||||
LoggerManager._writer = original_writer
|
||||
LoggerManager._log_path = original_log_path
|
||||
|
||||
Reference in New Issue
Block a user