diff --git a/openevolve/controller.py b/openevolve/controller.py index b05200c45..d7b2236da 100644 --- a/openevolve/controller.py +++ b/openevolve/controller.py @@ -3,6 +3,7 @@ """ import asyncio +import contextvars import logging import os import shutil @@ -26,6 +27,26 @@ logger = logging.getLogger(__name__) +# Per-task run id so concurrent OpenEvolve instances do not share handlers. +_current_run_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "openevolve_run_id", default=None +) + + +class _RunIdFilter(logging.Filter): + """Only emit records produced while this run is the active context.""" + + def __init__(self, run_id: str) -> None: + super().__init__() + self.run_id = run_id + + def filter(self, record: logging.LogRecord) -> bool: + current = _current_run_id.get() + if current is None: + return True + return current == self.run_id + + class OpenEvolve: """ @@ -60,7 +81,9 @@ def __init__( ) os.makedirs(self.output_dir, exist_ok=True) - # Set up logging + # Set up logging (isolated from the process root logger) + self._log_handlers: List[logging.Handler] = [] + self._log_token: Optional[contextvars.Token] = None self._setup_logging() # Manual mode queue lives in /manual_tasks_queue @@ -172,29 +195,56 @@ def __init__( self.parallel_controller = None def _setup_logging(self) -> None: - """Set up logging""" + """Set up per-instance logging on the openevolve logger, not the root logger.""" log_dir = self.config.log_dir or os.path.join(self.output_dir, "logs") os.makedirs(log_dir, exist_ok=True) - # Set up root logger - root_logger = logging.getLogger() - root_logger.setLevel(getattr(logging, self.config.log_level)) + self._run_id = uuid.uuid4().hex[:8] + self._log_token = _current_run_id.set(self._run_id) - # Add file handler - log_file = os.path.join(log_dir, f"openevolve_{time.strftime('%Y%m%d_%H%M%S')}.log") + package_logger = logging.getLogger("openevolve") + package_logger.setLevel(getattr(logging, self.config.log_level)) + package_logger.propagate = False + + run_filter = _RunIdFilter(self._run_id) + + log_file = os.path.join( + log_dir, f"openevolve_{time.strftime('%Y%m%d_%H%M%S')}_{self._run_id}.log" + ) file_handler = logging.FileHandler(log_file) file_handler.setFormatter( logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") ) - root_logger.addHandler(file_handler) + file_handler.addFilter(run_filter) + package_logger.addHandler(file_handler) - # Add console handler console_handler = logging.StreamHandler() - console_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) - root_logger.addHandler(console_handler) + console_handler.setFormatter( + logging.Formatter(f"%(asctime)s - [{self._run_id}] - %(levelname)s - %(message)s") + ) + console_handler.addFilter(run_filter) + package_logger.addHandler(console_handler) + self._log_handlers = [file_handler, console_handler] + self._log_file = log_file logger.info(f"Logging to {log_file}") + def _teardown_logging(self) -> None: + """Detach this instance's handlers so parallel runs stay isolated.""" + package_logger = logging.getLogger("openevolve") + for handler in getattr(self, "_log_handlers", []): + package_logger.removeHandler(handler) + handler.close() + self._log_handlers = [] + token = getattr(self, "_log_token", None) + if token is not None: + try: + _current_run_id.reset(token) + except ValueError: + if _current_run_id.get() == getattr(self, "_run_id", None): + _current_run_id.set(None) + self._log_token = None + def _setup_manual_mode_queue(self) -> None: """ Set up manual task queue directory if llm.manual_mode is enabled @@ -370,6 +420,8 @@ def force_exit_handler(signum, frame): self.evolution_tracer.close() logger.info("Evolution tracer closed") + self._teardown_logging() + # Get the best program best_program = None if self.database.best_program_id: diff --git a/tests/test_logging_isolation.py b/tests/test_logging_isolation.py new file mode 100644 index 000000000..6f102108d --- /dev/null +++ b/tests/test_logging_isolation.py @@ -0,0 +1,124 @@ +"""Tests that concurrent OpenEvolve instances do not attach to the root logger.""" + +import logging +import os +import tempfile +import unittest +from unittest.mock import patch + +from openevolve.config import Config +from openevolve.controller import OpenEvolve, _RunIdFilter, _current_run_id + + +def _write_minimal_files(directory: str): + program = os.path.join(directory, "program.py") + eval_file = os.path.join(directory, "evaluator.py") + with open(program, "w", encoding="utf-8") as handle: + handle.write("def solve():\n return 1\n") + with open(eval_file, "w", encoding="utf-8") as handle: + handle.write("def evaluate(program_path):\n return {'score': 1.0}\n") + return program, eval_file + + +def _minimal_config(): + config = Config() + config.database.in_memory = True + config.database.db_path = None + return config + + +class TestLoggingIsolation(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.test_dir = self._tmpdir.name + self._llm_patch = patch("openevolve.controller.LLMEnsemble") + self._llm_patch.start() + + def tearDown(self): + self._llm_patch.stop() + self._tmpdir.cleanup() + logging.getLogger("openevolve").handlers.clear() + _current_run_id.set(None) + + def test_does_not_attach_handlers_to_root_logger(self): + root_before = list(logging.getLogger().handlers) + program, eval_file = _write_minimal_files(self.test_dir) + controller = OpenEvolve( + initial_program_path=program, + evaluation_file=eval_file, + config=_minimal_config(), + output_dir=os.path.join(self.test_dir, "out"), + ) + try: + root_after = list(logging.getLogger().handlers) + self.assertEqual(root_before, root_after) + package = logging.getLogger("openevolve") + self.assertFalse(package.propagate) + self.assertGreaterEqual(len(controller._log_handlers), 2) + for handler in controller._log_handlers: + self.assertIn(handler, package.handlers) + finally: + controller._teardown_logging() + + def test_parallel_instances_use_distinct_handlers_and_filters(self): + left_dir = os.path.join(self.test_dir, "left") + right_dir = os.path.join(self.test_dir, "right") + os.makedirs(left_dir) + os.makedirs(right_dir) + left_prog, left_eval = _write_minimal_files(left_dir) + right_prog, right_eval = _write_minimal_files(right_dir) + + left = OpenEvolve( + initial_program_path=left_prog, + evaluation_file=left_eval, + config=_minimal_config(), + output_dir=os.path.join(left_dir, "out"), + ) + if left._log_token is not None: + _current_run_id.reset(left._log_token) + left._log_token = None + + right = OpenEvolve( + initial_program_path=right_prog, + evaluation_file=right_eval, + config=_minimal_config(), + output_dir=os.path.join(right_dir, "out"), + ) + try: + self.assertNotEqual(left._run_id, right._run_id) + self.assertFalse(set(left._log_handlers) & set(right._log_handlers)) + left_ids = { + f.run_id + for h in left._log_handlers + for f in h.filters + if isinstance(f, _RunIdFilter) + } + right_ids = { + f.run_id + for h in right._log_handlers + for f in h.filters + if isinstance(f, _RunIdFilter) + } + self.assertEqual(left_ids, {left._run_id}) + self.assertEqual(right_ids, {right._run_id}) + finally: + left._teardown_logging() + right._teardown_logging() + + def test_run_id_filter_matches_active_context(self): + filt = _RunIdFilter("aaa") + record = logging.LogRecord("n", logging.INFO, __file__, 1, "msg", (), None) + token = _current_run_id.set("aaa") + try: + self.assertTrue(filt.filter(record)) + finally: + _current_run_id.reset(token) + token = _current_run_id.set("bbb") + try: + self.assertFalse(filt.filter(record)) + finally: + _current_run_id.reset(token) + + +if __name__ == "__main__": + unittest.main()