""" LogTailThread — tails a server's log file, parses lines via LogParser, and persists parsed entries to the logs table. Design notes: - Opens the log file in text mode with errors="replace" to handle encoding issues - Detects log rotation by checking if the inode changes (Unix) or file shrinks (Windows) - On rotation: closes old handle, reopens from position 0 - Flushes inserts in batches of up to LOG_BATCH_SIZE per loop iteration """ from __future__ import annotations import logging import os import queue from pathlib import Path from typing import Callable, Optional from core.dal.log_repository import LogRepository from core.threads.base_thread import BaseServerThread logger = logging.getLogger(__name__) _LOG_BATCH_SIZE = 50 _POLL_INTERVAL = 1.0 _REOPEN_DELAY = 2.0 class LogTailThread(BaseServerThread): """ Tails a log file for a specific server. Args: server_id: The database server ID. log_path: Absolute path to the log file to tail. log_parser: LogParser adapter instance for this game type. broadcast_queue: Optional queue.Queue to push parsed events to BroadcastThread. """ def __init__( self, server_id: int, log_path: str, log_parser, broadcast_queue=None, ) -> None: super().__init__(server_id, "LogTail") self._log_path = log_path self._log_parser = log_parser self._broadcast_queue = broadcast_queue self._file_handle = None self._last_inode = None self._last_size = 0 # ── Lifecycle ── def _on_start(self) -> None: self._open_log_file() def _on_stop(self) -> None: self._close_file() # ── Main loop ── def _run_loop(self) -> None: if self._file_handle is None: self._stop_event.wait(timeout=_POLL_INTERVAL) self._open_log_file() return if self._detect_rotation(): logger.info("[%s] Log rotation detected, reopening", self.name) self._close_file() self._stop_event.wait(timeout=_REOPEN_DELAY) self._open_log_file() return lines_read = 0 entries_to_insert = [] while lines_read < _LOG_BATCH_SIZE: line = self._file_handle.readline() if not line: break lines_read += 1 line = line.rstrip("\n").rstrip("\r") if not line: continue parsed = self._log_parser.parse_line(line) if parsed is not None: entries_to_insert.append(parsed) if entries_to_insert and self._db is not None: log_repo = LogRepository(self._db) for entry in entries_to_insert: log_repo.insert(server_id=self.server_id, entry=entry) try: self._db.commit() except Exception as exc: logger.error("[%s] DB commit failed: %s", self.name, exc) self._db.rollback() if self._broadcast_queue is not None: for entry in entries_to_insert: try: self._broadcast_queue.put_nowait({ "type": "log", "server_id": self.server_id, "data": entry, }) except queue.Full: logger.debug("[%s] Broadcast queue full, dropping log event", self.name) if lines_read == 0: self._stop_event.wait(timeout=_POLL_INTERVAL) # ── File management ── def _open_log_file(self) -> None: if not os.path.exists(self._log_path): return try: self._file_handle = open( self._log_path, "r", encoding="utf-8", errors="replace" ) # Start tailing from the end of the file self._file_handle.seek(0, 2) self._last_size = self._file_handle.tell() stat = os.stat(self._log_path) self._last_inode = getattr(stat, "st_ino", None) logger.debug("[%s] Opened log file: %s", self.name, self._log_path) except OSError as exc: logger.warning("[%s] Cannot open log file %s: %s", self.name, self._log_path, exc) self._file_handle = None def _close_file(self) -> None: if self._file_handle is not None: try: self._file_handle.close() except OSError as exc: logger.debug("[%s] Error closing log file: %s", self.name, exc) self._file_handle = None self._last_inode = None self._last_size = 0 def _detect_rotation(self) -> bool: """Returns True if the log file has been rotated.""" try: stat = os.stat(self._log_path) except OSError: return True current_inode = getattr(stat, "st_ino", None) if current_inode is not None and self._last_inode is not None: if current_inode != self._last_inode: return True # Windows fallback: file shrunk current_size = stat.st_size if self._file_handle is not None: current_pos = self._file_handle.tell() if current_size < current_pos: return True self._last_size = current_size return False