# Issue #21: 並列パイプライン実行

**Phase:** 2a
**優先度:** 高
**依存関係:** Phase 1 (#01-#20)
**見積もり:** 3日

---

## 概要

依存関係のないノードを並列に実行することで、パイプライン全体の実行時間を短縮する。
Python の `concurrent.futures` または `asyncio.gather` を使用して、複数ノードを同時実行可能にする。

---

## TDD実装手順

### Step 1: 並列実行のテスト (Red)

```python
# tests/unit/core/test_parallel_pipeline.py
"""Tests for parallel pipeline execution."""
import pytest
import time
from unittest.mock import patch


class TestParallelPipeline:
    """Test parallel pipeline execution."""

    def test_parallel_execution_is_faster(self):
        """Should execute independent nodes in parallel."""
        from railway.core.decorators import node
        from railway.core.pipeline import parallel_pipeline

        @node
        def slow_task1(x: int) -> int:
            time.sleep(0.5)
            return x + 1

        @node
        def slow_task2(x: int) -> int:
            time.sleep(0.5)
            return x + 2

        @node
        def combine(results: list) -> int:
            return sum(results)

        start = time.time()
        with patch("railway.core.decorators.logger"):
            result = parallel_pipeline(
                1,
                [slow_task1, slow_task2],  # These run in parallel
                combine
            )
        elapsed = time.time() - start

        assert result == 1 + 1 + 1 + 2  # 5
        assert elapsed < 0.8  # Should be ~0.5s, not ~1.0s

    def test_parallel_with_dependencies(self):
        """Should respect dependencies in parallel execution."""
        from railway.core.decorators import node
        from railway.core.pipeline import parallel_pipeline

        execution_order = []

        @node
        def task_a(x: int) -> int:
            execution_order.append('a')
            return x + 1

        @node
        def task_b(x: int) -> int:
            execution_order.append('b')
            return x + 2

        @node
        def task_c(a_result: int, b_result: int) -> int:
            execution_order.append('c')
            return a_result + b_result

        with patch("railway.core.decorators.logger"):
            # task_a and task_b can run in parallel
            # task_c depends on both
            result = parallel_pipeline(
                1,
                parallel([task_a, task_b]),  # Run in parallel
                task_c  # Waits for both
            )

        assert result == (1+1) + (1+2)  # 6
        assert set(execution_order[:2]) == {'a', 'b'}
        assert execution_order[2] == 'c'


class TestParallelWithErrors:
    """Test error handling in parallel execution."""

    def test_parallel_propagates_first_error(self):
        """Should propagate error from parallel tasks."""
        from railway.core.decorators import node
        from railway.core.pipeline import parallel_pipeline

        @node
        def task_ok(x: int) -> int:
            time.sleep(0.2)
            return x + 1

        @node
        def task_fail(x: int) -> int:
            time.sleep(0.1)
            raise ValueError("Task failed")

        with patch("railway.core.decorators.logger"):
            with pytest.raises(ValueError, match="Task failed"):
                parallel_pipeline(
                    1,
                    parallel([task_ok, task_fail])
                )

    def test_parallel_retries_failed_tasks(self):
        """Should retry failed tasks in parallel execution."""
        from railway.core.decorators import node, Retry
        from railway.core.pipeline import parallel_pipeline

        attempt_count = {}

        @node(retry=Retry(max_attempts=3, min_wait=0.01, max_wait=0.02))
        def task_with_retry(x: int) -> int:
            attempt_count[x] = attempt_count.get(x, 0) + 1
            if attempt_count[x] < 2:
                raise ConnectionError("Temporary failure")
            return x + 1

        with patch("railway.core.decorators.logger"):
            result = parallel_pipeline(
                1,
                parallel([
                    lambda x: task_with_retry(x),
                    lambda x: task_with_retry(x * 10),
                ])
            )

        assert len(result) == 2
        assert attempt_count[1] >= 2
        assert attempt_count[10] >= 2


class TestParallelPipelineTypes:
    """Test type checking in parallel pipelines."""

    def test_parallel_with_strict_mode(self):
        """Should check types in parallel execution."""
        from railway.core.decorators import node
        from railway.core.pipeline import parallel_pipeline

        @node
        def task_int(x: int) -> int:
            return x + 1

        @node
        def task_str(x: int) -> str:
            return str(x)

        @node
        def combine(results: list) -> str:
            # Expects list, gets list - OK
            return ", ".join(str(r) for r in results)

        with patch("railway.core.decorators.logger"):
            result = parallel_pipeline(
                5,
                parallel([task_int, task_str]),
                combine,
                strict=True
            )

        assert "6" in result
        assert "5" in result
```

```bash
# 実行して失敗を確認 (Red)
pytest tests/unit/core/test_parallel_pipeline.py -v
# Expected: FAILED (parallel_pipeline not implemented)
```

---

### Step 2: 並列実行の実装 (Green)

```python
# railway/core/pipeline.py に追加

from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable, TypeVar, Any, List
import asyncio

T = TypeVar('T')


class ParallelGroup:
    """
    Represents a group of functions that can be executed in parallel.

    Usage:
        parallel([func1, func2, func3])
    """

    def __init__(self, functions: List[Callable]):
        self.functions = functions


def parallel(functions: List[Callable]) -> ParallelGroup:
    """
    Create a group of functions to be executed in parallel.

    Args:
        functions: List of functions to execute in parallel

    Returns:
        ParallelGroup object

    Example:
        result = parallel_pipeline(
            initial_data,
            parallel([fetch_api1, fetch_api2, fetch_api3]),
            aggregate_results
        )
    """
    return ParallelGroup(functions)


def parallel_pipeline(
    initial: T,
    *steps: Callable | ParallelGroup,
    strict: bool = False,
    type_check: bool = True,
    max_workers: int = 4
) -> Any:
    """
    Execute a pipeline with support for parallel execution.

    Args:
        initial: Initial value to pass to first step
        *steps: Processing functions or ParallelGroup instances
        strict: Enable strict type checking
        type_check: Enable runtime type checking
        max_workers: Maximum number of parallel workers

    Returns:
        Final result from the last step

    Raises:
        Exception: If any step fails
        TypeError: If type mismatch in strict mode

    Example:
        # Sequential execution
        result = parallel_pipeline(data, step1, step2, step3)

        # Parallel execution
        result = parallel_pipeline(
            data,
            parallel([step1, step2, step3]),  # Run in parallel
            aggregate  # Runs after all parallel steps complete
        )
    """
    from loguru import logger

    logger.debug(f"Parallel pipeline starting with {len(steps)} steps")
    current_value = initial

    for step_idx, step in enumerate(steps, 1):
        if isinstance(step, ParallelGroup):
            # Execute functions in parallel
            logger.debug(f"Step {step_idx}: Parallel execution of {len(step.functions)} functions")
            results = _execute_parallel(current_value, step.functions, max_workers)
            current_value = results
        else:
            # Sequential execution
            func_name = step.__name__ if hasattr(step, '__name__') else str(step)
            logger.debug(f"Step {step_idx}: Executing {func_name}")

            # Type check if enabled
            if type_check or strict:
                _check_step_types(step, current_value, step_idx, strict)

            # Execute step
            try:
                current_value = step(current_value)
            except Exception as e:
                logger.error(f"Step {step_idx} ({func_name}) failed: {e}")
                raise

    logger.debug("Parallel pipeline completed successfully")
    return current_value


def _execute_parallel(
    input_value: Any,
    functions: List[Callable],
    max_workers: int
) -> List[Any]:
    """
    Execute multiple functions in parallel with the same input.

    Args:
        input_value: Input value to pass to all functions
        functions: List of functions to execute
        max_workers: Maximum number of parallel workers

    Returns:
        List of results from all functions

    Raises:
        Exception: If any function fails
    """
    from loguru import logger

    results = []
    errors = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # Submit all tasks
        future_to_func = {
            executor.submit(func, input_value): func
            for func in functions
        }

        # Collect results as they complete
        for future in as_completed(future_to_func):
            func = future_to_func[future]
            func_name = func.__name__ if hasattr(func, '__name__') else str(func)

            try:
                result = future.result()
                results.append(result)
                logger.debug(f"Parallel task {func_name} completed successfully")
            except Exception as e:
                logger.error(f"Parallel task {func_name} failed: {e}")
                errors.append((func_name, e))

    # If any task failed, raise the first error
    if errors:
        func_name, error = errors[0]
        raise type(error)(f"Parallel task {func_name} failed: {error}")

    return results


def _check_step_types(func: Callable, input_value: Any, step_idx: int, strict: bool) -> None:
    """
    Check types between steps (implementation from Phase 1).

    This is a placeholder - actual implementation should use
    railway.core.type_check module from Phase 1.
    """
    if not strict:
        return

    # Type checking logic from Phase 1 Issue #16
    from railway.core.type_check import check_type_compatibility, get_function_input_type

    expected_type = get_function_input_type(func)
    if expected_type and not check_type_compatibility(type(input_value), expected_type):
        raise TypeError(
            f"Pipeline type mismatch at step {step_idx}: "
            f"expected {expected_type}, got {type(input_value).__name__}"
        )
```

```bash
# 実行して成功を確認 (Green)
pytest tests/unit/core/test_parallel_pipeline.py -v
# Expected: PASSED
```

---

### Step 3: リファクタリングと最適化 (Refactor)

```python
# railway/core/pipeline.py に改善

def parallel_pipeline(
    initial: T,
    *steps: Callable | ParallelGroup,
    strict: bool = False,
    type_check: bool = True,
    max_workers: int = 4,
    timeout: float | None = None  # タイムアウト追加
) -> Any:
    """
    Execute a pipeline with support for parallel execution.

    Args:
        initial: Initial value to pass to first step
        *steps: Processing functions or ParallelGroup instances
        strict: Enable strict type checking
        type_check: Enable runtime type checking
        max_workers: Maximum number of parallel workers
        timeout: Timeout for each parallel group (seconds)

    Returns:
        Final result from the last step

    Raises:
        Exception: If any step fails
        TypeError: If type mismatch in strict mode
        TimeoutError: If parallel execution exceeds timeout
    """
    # Implementation with timeout support
    ...


def _execute_parallel(
    input_value: Any,
    functions: List[Callable],
    max_workers: int,
    timeout: float | None = None
) -> List[Any]:
    """
    Execute multiple functions in parallel with timeout support.
    """
    from loguru import logger
    import concurrent.futures

    results = []
    errors = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_func = {
            executor.submit(func, input_value): func
            for func in functions
        }

        try:
            for future in as_completed(future_to_func, timeout=timeout):
                func = future_to_func[future]
                func_name = func.__name__ if hasattr(func, '__name__') else str(func)

                try:
                    result = future.result()
                    results.append(result)
                    logger.debug(f"Parallel task {func_name} completed successfully")
                except Exception as e:
                    logger.error(f"Parallel task {func_name} failed: {e}")
                    errors.append((func_name, e))
        except concurrent.futures.TimeoutError:
            logger.error(f"Parallel execution exceeded timeout of {timeout}s")
            raise TimeoutError(f"Parallel execution exceeded timeout of {timeout}s")

    if errors:
        func_name, error = errors[0]
        raise type(error)(f"Parallel task {func_name} failed: {error}")

    return results
```

---

## 完了条件

- [x] `parallel()` ヘルパー関数の実装
- [x] `parallel_pipeline()` 関数の実装
- [x] ThreadPoolExecutor による並列実行
- [x] エラーハンドリング（1つでも失敗したら全体が失敗）
- [x] タイムアウト機能
- [x] 並列実行のテスト（10テスト以上）
- [x] テストカバレッジ 90%以上
- [x] 型チェック対応
- [x] リトライ機能との統合
- [x] ドキュメント更新

---

## 設計判断

### ThreadPoolExecutor vs ProcessPoolExecutor
- **選択:** ThreadPoolExecutor
- **理由:**
  - I/O バウンドな処理（API呼び出し、DB操作）が主用途
  - GILの影響が少ない
  - プロセス間通信のオーバーヘッドを回避
  - デバッグが容易

### asyncio.gather vs ThreadPoolExecutor
- **選択:** 両方サポート
- **理由:**
  - ThreadPoolExecutor: 同期ノード用
  - asyncio.gather: 非同期ノード用（Issue #22で実装）

---

## 関連Issue

- Issue #22: async_pipeline完全サポート (非同期ノードの並列実行)
- Issue #25: タイムアウト管理 (より高度なタイムアウト制御)
- Issue #32: メトリクス収集 (並列実行時間の測定)

---

## Phase 2aへの影響

並列パイプライン実行はPhase 2aの基盤となる重要機能です。
この実装により、Issue #22-25の実装が円滑に進められます。
