|
5 | 5 | import asyncio |
6 | 6 | import logging |
7 | 7 | import multiprocessing as mp |
8 | | -import pickle |
9 | | -import signal |
10 | 8 | import time |
11 | | -from concurrent.futures import Future, ProcessPoolExecutor |
| 9 | +from concurrent.futures import BrokenExecutor, Future, ProcessPoolExecutor |
12 | 10 | from concurrent.futures import TimeoutError as FutureTimeoutError |
13 | 11 | from dataclasses import asdict, dataclass |
14 | | -from pathlib import Path |
15 | | -from typing import Any, Dict, List, Optional, Tuple |
| 12 | +from typing import Any, Dict, List, Optional |
16 | 13 |
|
17 | 14 | from openevolve.config import Config |
18 | 15 | from openevolve.database import Program, ProgramDatabase |
@@ -357,6 +354,10 @@ def __init__( |
357 | 354 | self.num_workers = config.evaluator.parallel_evaluations |
358 | 355 | self.num_islands = config.database.num_islands |
359 | 356 |
|
| 357 | + # Recovery tracking for process pool crashes |
| 358 | + self.recovery_attempts = 0 |
| 359 | + self.max_recovery_attempts = 3 |
| 360 | + |
360 | 361 | logger.info(f"Initialized process parallel controller with {self.num_workers} workers") |
361 | 362 |
|
362 | 363 | def _serialize_config(self, config: Config) -> dict: |
@@ -434,6 +435,38 @@ def stop(self) -> None: |
434 | 435 |
|
435 | 436 | logger.info("Stopped process pool") |
436 | 437 |
|
| 438 | + def _recover_process_pool(self, failed_iterations: list[int] | None = None) -> None: |
| 439 | + """Recover from a crashed process pool by recreating it. |
| 440 | +
|
| 441 | + Args: |
| 442 | + failed_iterations: List of iteration numbers that failed and need re-queuing |
| 443 | + """ |
| 444 | + import gc |
| 445 | + |
| 446 | + logger.warning("Process pool crashed, attempting recovery...") |
| 447 | + |
| 448 | + # Shutdown broken executor without waiting (it's already broken) |
| 449 | + if self.executor: |
| 450 | + try: |
| 451 | + self.executor.shutdown(wait=False, cancel_futures=True) |
| 452 | + except Exception: |
| 453 | + pass # Executor may already be in bad state |
| 454 | + self.executor = None |
| 455 | + |
| 456 | + # Force garbage collection to free memory before restarting |
| 457 | + gc.collect() |
| 458 | + |
| 459 | + # Brief delay to let system stabilize (memory freed, processes cleaned up) |
| 460 | + time.sleep(2.0) |
| 461 | + |
| 462 | + # Recreate the pool |
| 463 | + self.start() |
| 464 | + |
| 465 | + if failed_iterations: |
| 466 | + logger.info(f"Pool recovered. {len(failed_iterations)} iterations will be re-queued.") |
| 467 | + else: |
| 468 | + logger.info("Pool recovered successfully.") |
| 469 | + |
437 | 470 | def request_shutdown(self) -> None: |
438 | 471 | """Request graceful shutdown""" |
439 | 472 | logger.info("Graceful shutdown requested...") |
@@ -559,6 +592,14 @@ async def run_evolution( |
559 | 592 | # Reconstruct program from dict |
560 | 593 | child_program = Program(**result.child_program_dict) |
561 | 594 |
|
| 595 | + # Reset recovery counter on successful iteration |
| 596 | + if self.recovery_attempts > 0: |
| 597 | + logger.info( |
| 598 | + f"Pool stable after recovery, resetting recovery counter " |
| 599 | + f"(was {self.recovery_attempts})" |
| 600 | + ) |
| 601 | + self.recovery_attempts = 0 |
| 602 | + |
562 | 603 | # Add to database with explicit target_island to ensure proper island placement |
563 | 604 | # This fixes issue #391: children should go to the target island, not inherit |
564 | 605 | # from the parent (which may be from a different island due to fallback sampling) |
@@ -752,6 +793,38 @@ async def run_evolution( |
752 | 793 | ) |
753 | 794 | # Cancel the future to clean up the process |
754 | 795 | future.cancel() |
| 796 | + except BrokenExecutor as e: |
| 797 | + logger.error(f"Process pool crashed during iteration {completed_iteration}: {e}") |
| 798 | + |
| 799 | + # Collect all failed iterations from pending futures |
| 800 | + failed_iterations = [completed_iteration] + list(pending_futures.keys()) |
| 801 | + |
| 802 | + # Clear pending futures (they're all invalid now) |
| 803 | + pending_futures.clear() |
| 804 | + for island_id in island_pending: |
| 805 | + island_pending[island_id].clear() |
| 806 | + |
| 807 | + # Attempt recovery |
| 808 | + self.recovery_attempts += 1 |
| 809 | + if self.recovery_attempts > self.max_recovery_attempts: |
| 810 | + logger.error( |
| 811 | + f"Max recovery attempts ({self.max_recovery_attempts}) exceeded. " |
| 812 | + f"Stopping evolution." |
| 813 | + ) |
| 814 | + break |
| 815 | + |
| 816 | + self._recover_process_pool(failed_iterations) |
| 817 | + |
| 818 | + # Re-queue failed iterations (distribute across islands) |
| 819 | + for i, failed_iter in enumerate(failed_iterations): |
| 820 | + if failed_iter < total_iterations: |
| 821 | + island_id = i % self.num_islands |
| 822 | + future = self._submit_iteration(failed_iter, island_id) |
| 823 | + if future: |
| 824 | + pending_futures[failed_iter] = future |
| 825 | + island_pending[island_id].append(failed_iter) |
| 826 | + |
| 827 | + continue |
755 | 828 | except Exception as e: |
756 | 829 | logger.error(f"Error processing result from iteration {completed_iteration}: {e}") |
757 | 830 |
|
@@ -822,6 +895,9 @@ def _submit_iteration( |
822 | 895 |
|
823 | 896 | return future |
824 | 897 |
|
| 898 | + except BrokenExecutor: |
| 899 | + # Let this propagate up to run_evolution for recovery |
| 900 | + raise |
825 | 901 | except Exception as e: |
826 | 902 | logger.error(f"Error submitting iteration {iteration}: {e}") |
827 | 903 | return None |
0 commit comments